서론
온라인 인기글, 핫한 주제등을 한 눈에 보여줌으로 통하여 영감을 제공하는 사이트를 개발하고 있다.
다시 말해서 종합 크롤러 사이트인데, 우리 서비스에서 사용하는 크롤러들을 관리할 수 있는 페이지를 개발하였다.
여기서 사용한 라이브러리가 AdminJS이다. Nest.js에서 사용할 수 있는 Admin 라이브러리는 3가지다.
선택 및 비선택 사유
Admin Bro:
Admin Bro는 Beta 버전이라고 적혀있어서 패스했다. 다만, 개발을 진행해보면서 많은 깃헙 레퍼지토리를 확인해보니 Admin Bro를 이용하여 개발한 프로젝트도 꽤나 있었고, 개발 커뮤니티에서 질문글도 많았으며, 무엇보다 AdminJS와 같은 사에서 만들어서 거의 유사하다.
Admin Bro로 프로젝트를 해도 큰 문제가 없을듯하다.
AdminJS:
여러 유명하신분들의 추천, 많은 깃헙 레퍼지토리, 그리고 무엇보다 Admin Bro보다 안정적인 버전이기 때문에 이를 선택했다.
문서도 깔끔하고 잘 되어 있다.
NestJS Admin:
문서가 불친절해서 사용하지 않았다. 가장 최근 업데이트가 2년 전이었다.
사용 스택
- AdminJS
- NestJS
- NestJS Task Scheduling
- TypeORM
구성하기
yarn add adminjs @adminjs/nestjs @adminjs/typeorm @adminjs/express express-session express-formidable
https://docs.adminjs.co/installation/getting-started
app.module.ts:
import { Module } from '@nestjs/common'
import { AdminModule } from '@adminjs/nestjs'
import { AppController } from './app.controller'
import { AppService } from './app.service'
const DEFAULT_ADMIN = {
email: 'admin@example.com',
password: 'password',
}
const authenticate = async (email: string, password: string) => {
if (email === DEFAULT_ADMIN.email && password === DEFAULT_ADMIN.password) {
return Promise.resolve(DEFAULT_ADMIN)
}
return null
}
@Module({
imports: [
AdminModule.createAdminAsync({
useFactory: () => ({
adminJsOptions: {
rootPath: '/admin',
resources: [],
},
auth: {
authenticate,
cookieName: 'adminjs',
cookiePassword: 'secret'
},
sessionOptions: {
resave: true,
saveUninitialized: true,
secret: 'secret'
},
}),
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
주의할 점: main에서 app을 생성할 때 Fastify를 사용하지 말것, 이것때문에 디버깅에 애를 먹었다.
express는 무관함.
이제 실행하면 잘 작동한다.
$ nest start
localhost:3000/admin 여기로 접속해서 첫 페이지가 잘 나오는지 확인해보세요.
AdminJS에서 UI로 함수 사용하기
결과물은 이렇습니다.
구현 방법을 스스로 고민해서 만들어보았다.
AdminJS에 리소스 추가하기
우선 Task 테이블을 생성하여 만약 서버가 닫히는 일이 있더라도 바로 작동되도록 만들어주었다.
import {
BaseEntity,
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Community } from '@domain/post/community.entity';
import { TaskProperties, TaskStatus } from '@domain/task/task';
@Entity('tasks')
export class Task extends BaseEntity implements TaskProperties {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column()
description: string;
@ManyToOne(() => Community, (community) => community, { eager: true })
taskType: Community;
@Column({
type: 'enum',
enum: TaskStatus,
default: TaskStatus.STOPPED,
})
status: TaskStatus;
}
AdminJS에서 Resource로 사용하려면 BaseEntity를 상속해야한다.
그리고 AdminJS에 추가해준다.
import { Module } from '@nestjs/common'
import { AdminModule } from '@adminjs/nestjs'
import { AppController } from './app.controller'
import { AppService } from './app.service'
const DEFAULT_ADMIN = {
email: 'admin@example.com',
password: 'password',
}
const authenticate = async (email: string, password: string) => {
if (email === DEFAULT_ADMIN.email && password === DEFAULT_ADMIN.password) {
return Promise.resolve(DEFAULT_ADMIN)
}
return null
}
@Module({
imports: [
AdminModule.createAdminAsync({
useFactory: () => ({
adminJsOptions: {
rootPath: '/admin',
resources: [
resource: Task,
],
},
}),
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
AdminJS Resource 탭에서 확인할 수 있습니다.
관리페이지에서 보여줘야하는 리소스를 생각해보고 추가해보세요.
관리페이지에서 사용 할 수 있는 함수 추가하기
{
resource: Task,
options: {
actions: {
runTask: {
actionType: 'record',
handler: async (req, res, context) => {
const task = context.record.params;
await tasksService.runTask(
task.id,
task['taskType.title'],
1,
);
task.status = TaskStatus.RUNNING;
return {
record: context.record.toJSON(),
};
},
component: false,
icon: 'Play',
},
stopTask: {
actionType: 'record',
handler: async (req, res, context) => {
const { record } = context;
const task = record.params;
await tasksService.stopTask(
task.id,
task['taskType.title'],
);
task.status = TaskStatus.STOPPED;
return {
record: context.record.toJSON(),
msg: 'Hello world',
};
},
guard: '크롤러를 종료하겠습니까?',
component: false,
icon: 'Stop',
},
},
},
},
위 관리 페이지에서 Run Task / Stop Task는 제가 만들어둔 함수에 매핑해둔거에요.
이 내용도 AdminJS Action 탭에서 확인할 수 있습니다.
동적 스케쥴링을 이용하여 함수 만들기
함수는 NestJS Task Schduling 탭에서 동적 설계 부분을 읽고 구현했습니다.
enum + Factory 패턴으로 구현해봤습니다.
import { Injectable, Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { DcInsideCrawlerService } from '@app/crawler/dc-inside-crawler/dc-inside-crawler.service';
import { RedditCrawlerService } from '@app/crawler/reddit-crawler/reddit-crawler.service';
import { TwitterCrawlerService } from '@app/crawler/twitter-crawler/twitter-crawler.service';
import { YoutubeCrawlerService } from '@app/crawler/youtube-crawler/youtube-crawler.service';
import { CommunityTitle } from '@domain/post/post';
@Injectable()
export class TaskFactory {
constructor(
private readonly redditCrawlerService: RedditCrawlerService,
private readonly dcInsideCrawlerService: DcInsideCrawlerService,
private readonly twitterCrawlerService: TwitterCrawlerService,
private readonly youtubeCrawlerService: YoutubeCrawlerService,
private readonly schedulerRegistry: SchedulerRegistry,
) {}
private readonly logger = new Logger(TaskFactory.name);
async runTask(taskType: CommunityTitle, minute: number): Promise<boolean> {
if (taskType === CommunityTitle.REDDIT) {
const milliseconds = 1000 * 60 * minute;
this.logger.warn(`Interval ${taskType} created!`);
const callback = () => {
this.logger.warn(
`Interval ${taskType} executing at time (${milliseconds})!`,
);
this.redditCrawlerService.crawlReddit();
};
const interval = setInterval(callback, milliseconds);
this.schedulerRegistry.addInterval(taskType, interval);
return true;
}
if (taskType === CommunityTitle.TWITTER) {
const milliseconds = 1000 * 60 * minute;
this.logger.warn(`Interval ${taskType} created!`);
const callback = () => {
this.logger.warn(
`Interval ${taskType} executing at time (${milliseconds})!`,
);
this.twitterCrawlerService.getTopics('23424868');
};
const interval = setInterval(callback, milliseconds);
this.schedulerRegistry.addInterval(taskType, interval);
return true;
}
if (taskType === CommunityTitle.YOUTUBE) {
const milliseconds = 1000 * 60 * minute;
this.logger.warn(`Interval ${taskType} created!`);
const callback = () => {
this.logger.warn(
`Interval ${taskType} executing at time (${milliseconds})!`,
);
this.youtubeCrawlerService.getVideos();
};
const interval = setInterval(callback, milliseconds);
this.schedulerRegistry.addInterval(taskType, interval);
return true;
}
if (taskType === CommunityTitle.DC_INSIDE) {
const milliseconds = 1000 * 60 * minute;
this.logger.warn(`Interval ${taskType} created!`);
const callback = () => {
this.logger.warn(
`Interval ${taskType} executing at time (${milliseconds})!`,
);
this.dcInsideCrawlerService.crawlDcInside();
};
const interval = setInterval(callback, milliseconds);
this.schedulerRegistry.addInterval(taskType, interval);
return true;
}
}
async stopTask(taskType: CommunityTitle): Promise<boolean> {
if (taskType === CommunityTitle.REDDIT) {
this.schedulerRegistry.deleteInterval(taskType);
this.logger.warn(`Interval ${taskType} deleted!`);
return true;
}
if (taskType === CommunityTitle.TWITTER) {
this.schedulerRegistry.deleteInterval(taskType);
this.logger.warn(`Interval ${taskType} deleted!`);
return true;
}
if (taskType === CommunityTitle.YOUTUBE) {
this.schedulerRegistry.deleteInterval(taskType);
this.logger.warn(`Interval ${taskType} deleted!`);
return true;
}
if (taskType === CommunityTitle.DC_INSIDE) {
this.schedulerRegistry.deleteInterval(taskType);
this.logger.warn(`Interval ${taskType} deleted!`);
return true;
}
}
}
참고 자료
NestJS Task Scheduling | https://docs.nestjs.com/techniques/task-scheduling
adminJS | https://docs.adminjs.co/basics/resource
'트러블 슈팅' 카테고리의 다른 글
팀원이 설정한 알람이 내 폰에서 울려야한다. (0) | 2023.03.28 |
---|---|
어떨 때 composite key를 사용해야 할까? (0) | 2023.03.19 |
OAuth 로그인을 하는데 새로운 계정이 생성되면 안된다. (0) | 2023.02.23 |
[Node.js] 싱글 스레드 언어가 어떻게 비동기 처리가 가능한가?, Node.js 동작 원리 (0) | 2023.01.26 |
[puppeteer] 크롤러 성능향상, 이미지 수신 차단 (0) | 2023.01.02 |
글 내용 중 잘못되거나 이해되지 않는 부분은 댓글을 달아주세요! 감사합니다! 문의: puleugo@gmail.com