Pozbądź się ciasno powiązanych modułów i zależności cyklicznych w NestJS
Jak zdarzenia pomagają modułom NestJS trzymać się swoich granic — i pozbyć się zależności cyklicznych bez rozsiewania forwardRef() po całym kodzie.
NestJS to świetny framework dla Node.js, który wnosi sporo świeżości do ekosystemu backendów w Node. Dzięki rozbudowanemu systemowi modułów pozwala zaplanować i zbudować skalowalną architekturę złożoną z modułów grupujących powiązaną logikę. Pracując w tak modułowym środowisku, możesz czasem natknąć się na zależności cykliczne wynikające z ciasno powiązanych modułów. Najczęściej rozpoznasz je po błędzie podobnym do poniższego.
[Nest] 2788 - 06/10/2023, 12:56:50 PM LOG [InjectorLogger]
> Nest encountered an undefined dependency.
> This may be due to a circular import or a missing dependency declaration.
[Nest] 2788 - 06/10/2023, 12:56:50 PM ERROR [ExceptionHandler]
> Nest can't resolve dependencies of the UserService (?).
> Please make sure that the argument dependency at index [0] is available in the UserModule context.Przyczyną są dwa serwisy, które do działania potrzebują siebie nawzajem. W powyższym przykładzie PostService jest zależnością UserService, ale UserService jest też zależnością PostService. Kontener wstrzykiwania zależności Nesta nie potrafi rozwiązać takiej sytuacji.
Zgodnie z dokumentacją Nesta możesz oczywiście użyć funkcji forwardRef(). To jednak tylko tymczasowe obejście. Jeśli naprawdę nie rozwiążesz problemu ciasnych powiązań i zależności cyklicznych, dodawanie kolejnych modułów stanie się bolesne, a większość zależności skończy opakowana w tę funkcję.
Ten artykuł proponuje inne rozwiązanie, które nie pasuje do każdego przypadku. Ale nawet jeśli rozwiąże tylko połowę Twoich zależności cyklicznych, to już spory krok naprzód. Bez zbędnych wstępów — spójrzmy na przykładowy problem i proponowane rozwiązanie.
Problem
Weźmy niezbyt prostą aplikację do pisania postów na bloga. Poza samym tworzeniem postów mamy zaimplementować szereg dodatkowych akcji: powiadamianie obserwujących autora, zwiększanie jego reputacji po publikacji, pobieranie opłaty za posty za paywallem i kilka innych efektownych funkcji. W efekcie możemy skończyć z kodem podobnym do poniższego.
class PostService {
constructor(
private readonly postRepository: PostRepository,
private readonly userService: UserService,
private readonly reputationService: ReputationService,
private readonly notificationService: NotificationService,
private readonly trackingService: TrackingService,
private readonly paymentService: PaymentService,
private readonly moderationService: ModerationService,
) {}
public createPost(args: CreatePostArgs): Post {
const post = this.postRepository.create(args);
this.reputationService.increaseReputation(args.userId);
this.notificationService.notifyFollowersAboutPost(post);
this.userService.updateUserActivity('post.created', post);
this.trackingService.registerTrackable('post', post);
this.moderationService.checkPostContentForViolations(post.content);
if (args.isPremium) {
this.paymentService.chargeUserForPremiumPost(args.userId);
}
return post;
}
}Przykład jest oczywiście zmyślony, więc nie zwracaj uwagi na szczegóły. Chodzi o to, by zauważyć, od ilu serwisów zależy PostService, i wyobrazić sobie, że te serwisy mogą także zależeć od PostService. Serwis powiadomień może potrzebować dodatkowych danych o poście, a ModerationService — ponownie zmodyfikować post przez PostService po moderacji. I tak powstają zależności cykliczne.
Rozwiązanie
Proponowane rozwiązanie to dobrze znana architektura sterowana zdarzeniami. Zamiast wywoływać wszystkie kolejne akcje z metody createPost, tworzymy w niej tylko post i emitujemy zdarzenie. Każdy moduł zainteresowany reakcją na to zdarzenie może to zrobić bez przekraczania swoich logicznych granic.
NestJS ma już gotowe narzędzia do pracy ze zdarzeniami. Jeśli nie masz jeszcze zainstalowanego pakietu, dodaj @nestjs/event-emitter do aplikacji.
yarn add @nestjs/event-emitterPo instalacji dodaj EventEmitterModule do głównego modułu aplikacji.
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({
imports: [
EventEmitterModule.forRoot(),
// ...
],
})
export class AppModule {}Teraz możemy pozbyć się wszystkich zależności PostService i zastąpić je jedną — EventEmitter2. Usuwamy też kolejne wywołania z createPost i zamiast nich emitujemy zdarzenie z kluczem i danymi. Dane mogą być osobnym interfejsem lub klasą, ale dla prostoty emitujemy po prostu utworzony post.
class PostsService {
constructor(private eventEmitter: EventEmitter2) {}
public createPost(args: CreatePostArgs): Post {
const post = this.db.posts.create({ ... });
this.eventEmitter.emit('post.created', post);
return post;
}
}Ostatni krok to dodanie listenera w modułach zainteresowanych tym zdarzeniem. W module powiadomień może on wyglądać jak poniżej. W pozostałych modułach kod będzie bardzo podobny — zdarzenie obsłużą po prostu inne serwisy.
class NotificationsListener {
constructor(
private readonly notificationsService: NotificationsService,
) {}
@OnEvent('post.created')
handlePostCreated(post: Post) {
this.notificationsService.notifyFollowersAboutPost(post);
}
}Dzięki temu NotificationService i PostService są ze sobą luźno powiązane. PostService nie zależy już od NotificationService. Pozbyliśmy się zależności cyklicznej, a funkcjonalność dalej działa — hura!
Podsumowanie
W tym artykule pokazaliśmy, jak wprowadzenie zdarzeń do aplikacji rozwiązuje problem zależności cyklicznych. Takie podejście pozwala modułom trzymać się swoich granic, a jednocześnie reagować na akcje wykonywane w innych modułach. Nie sprawdzi się wszędzie, ale z pewnością rozwiązuje przypadek opisany dzisiaj.