사용자 지정 콜레이터 작성하기
사용자 지정 콜레이터 작성하기 (Writing Custom Collators)
어떤 데이터 소스든 인덱싱해서 Backstage에서 검색 가능하게 만드는 나만의 콜레이터를 만들 수 있어요.
출처: 문서
본문
어떤 데이터 소스든 인덱싱해서 Backstage에서 검색 가능하게 만드는 나만의 콜레이터를 만들 수 있어요. 권장하는 방법은 내장 템플릿으로 콜레이터 모듈을 스캐폴딩한 다음, 데이터 가져오기 로직을 구현하는 것이에요.
콜레이터 모듈 스캐폴딩하기
Backstage 루트 디렉터리에서 다음 명령을 실행해요.
Backstage 루트 디렉터리에서
yarn new --select search-collator-module
모듈 ID를 입력하라는 프롬프트가 나타나는데, 이 ID는 패키지 이름과 생성되는 콜레이터 클래스 이름을 지정하는 데 사용돼요. 이 예시에서는 blog-posts라고 입력하세요.
템플릿은 plugins/search-backend-module-blog-posts/에 다음 구조로 새 패키지를 만들고, 모듈을 백엔드에 자동으로 추가해요.
packages/backend/src/index.ts
backend.add(import('@internal/plugin-search-backend-module-blog-posts'));
생성된 패키지에는 다음 파일들이 있어요.
plugins/search-backend-module-blog-posts/├── config.d.ts├── package.json└── src/ ├── collator/ │ ├── BlogPostsCollatorFactory.test.ts │ └── BlogPostsCollatorFactory.ts ├── index.ts └── module.ts
생성된 코드 이해하기
템플릿은 주목할 만한 파일 두 개를 생성해요. 콜레이터를 검색 시스템에 연결하는 백엔드 모듈과, 문서를 가져오고 산출(yield)하는 콜레이터 팩토리예요.
백엔드 모듈
src/module.ts 파일은 콜레이터를 검색 인덱스에 등록하는 백엔드 모듈을 만들어요. 구성에서 선택적 스케줄을 읽고, 기본 스케줄(10분마다)로 대체돼요.
plugins/search-backend-module-blog-posts/src/module.ts
import { coreServices, createBackendModule, readSchedulerServiceTaskScheduleDefinitionFromConfig,} from '@backstage/backend-plugin-api';import { searchIndexRegistryExtensionPoint } from '@backstage/plugin-search-backend-node/alpha';import { BlogPostsCollatorFactory } from './collator/BlogPostsCollatorFactory';const DEFAULT_SCHEDULE = { frequency: { minutes: 10 }, timeout: { minutes: 15 }, initialDelay: { seconds: 3 },};export const searchModuleBlogPosts = createBackendModule({ pluginId: 'search', moduleId: 'blog-posts-collator', register({ registerInit }) { registerInit({ deps: { config: coreServices.rootConfig, logger: coreServices.logger, scheduler: coreServices.scheduler, indexRegistry: searchIndexRegistryExtensionPoint, }, async init({ config, logger, scheduler, indexRegistry }) { const scheduleConfig = config .getOptionalConfig('search.collators.blogPosts') ?.getOptionalConfig('schedule'); const schedule = scheduleConfig ? readSchedulerServiceTaskScheduleDefinitionFromConfig(scheduleConfig) : DEFAULT_SCHEDULE; indexRegistry.addCollator({ schedule: scheduler.createScheduledTaskRunner(schedule), factory: BlogPostsCollatorFactory.fromConfig(config, { logger }), }); }, }); },});
콜레이터 팩토리
src/collator/BlogPostsCollatorFactory.ts 파일은 DocumentCollatorFactory 인터페이스를 구현해요. execute() 메서드는 IndexableDocument 객체를 산출하는 비동기 생성기(async generator)예요. 각 문서는 title, text, location 필드를 포함해야 해요.
plugins/search-backend-module-blog-posts/src/collator/BlogPostsCollatorFactory.ts
import { LoggerService } from '@backstage/backend-plugin-api';import { Config } from '@backstage/config';import { DocumentCollatorFactory, IndexableDocument,} from '@backstage/plugin-search-common';import { Readable } from 'node:stream';export type BlogPostsCollatorFactoryOptions = { logger: LoggerService;};export class BlogPostsCollatorFactory implements DocumentCollatorFactory { public readonly type = 'blog-posts'; private readonly logger: LoggerService; static fromConfig( _config: Config, options: BlogPostsCollatorFactoryOptions, ): BlogPostsCollatorFactory { return new BlogPostsCollatorFactory(options); } private constructor(options: BlogPostsCollatorFactoryOptions) { this.logger = options.logger; } async getCollator(): Promise<Readable> { return Readable.from(this.execute()); } private async *execute(): AsyncGenerator<IndexableDocument> { this.logger.info('Collating documents for blog-posts'); // TODO: Replace with your data fetching logic yield* []; }}
콜레이터 구현하기
콜레이터를 유용하게 만들려면 자리표시자 execute() 메서드를 자신의 데이터 가져오기 로직으로 교체해요. 산출되는 각 객체는 최소한 title, text, location을 포함해야 해요.
다음 예시는 내부 API에서 블로그 게시물을 가져와요.
plugins/search-backend-module-blog-posts/src/collator/BlogPostsCollatorFactory.ts
import { LoggerService } from '@backstage/backend-plugin-api';import { Config } from '@backstage/config';import { DocumentCollatorFactory, IndexableDocument,} from '@backstage/plugin-search-common';import { Readable } from 'node:stream';type BlogPost = { id: string; title: string; body: string; author: string;};export type BlogPostsCollatorFactoryOptions = { logger: LoggerService;};export class BlogPostsCollatorFactory implements DocumentCollatorFactory { public readonly type = 'blog-posts'; private readonly baseUrl: string; private readonly logger: LoggerService; static fromConfig( config: Config, options: BlogPostsCollatorFactoryOptions, ): BlogPostsCollatorFactory { const baseUrl = config.getString('blogPosts.baseUrl'); return new BlogPostsCollatorFactory(baseUrl, options); } private constructor( baseUrl: string, options: BlogPostsCollatorFactoryOptions, ) { this.baseUrl = baseUrl; this.logger = options.logger; } async getCollator(): Promise<Readable> { return Readable.from(this.execute()); } private async *execute(): AsyncGenerator<IndexableDocument> { this.logger.info('Collating documents for blog-posts'); const response = await fetch(`${this.baseUrl}/blog-posts`); const posts: BlogPost[] = await response.json(); for (const post of posts) { yield { title: post.title, text: post.body, location: `/blog-posts/${post.id}`, }; } }}
:::tip
대규모 데이터 집합의 경우 execute() 메서드에서 커서 기반 페이지네이션을 사용해서 모든 레코드를 한 번에 메모리에 로드하지 않도록 해요.
:::
private async *execute(): AsyncGenerator<IndexableDocument> { let cursor: string | undefined = undefined; do { const url = cursor ? `${this.baseUrl}/blog-posts?cursor=${cursor}` : `${this.baseUrl}/blog-posts`; const response = await fetch(url); const { items, nextCursor } = await response.json(); for (const item of items) { yield { title: item.title, text: item.body, location: `/blog-posts/${item.id}`, }; } cursor = nextCursor; } while (cursor);}
콜레이터 테스트하기
src/collator/BlogPostsCollatorFactory.test.ts에 생성된 테스트 파일은 @backstage/plugin-search-backend-node의 TestPipeline을 사용해 콜레이터를 실행하고 출력을 검증해요. 구현에 맞게 테스트를 업데이트하세요.
plugins/search-backend-module-blog-posts/src/collator/BlogPostsCollatorFactory.test.ts
import { BlogPostsCollatorFactory } from './BlogPostsCollatorFactory';import { mockServices } from '@backstage/backend-test-utils';import { TestPipeline } from '@backstage/plugin-search-backend-node';const mockPosts = [ { id: '1', title: 'Getting Started', body: 'Welcome to our engineering blog', author: 'Alice', }, { id: '2', title: 'Best Practices', body: 'Tips for writing great code', author: 'Bob', },];describe('BlogPostsCollatorFactory', () => { beforeEach(() => { global.fetch = jest.fn().mockResolvedValue({ json: async () => mockPosts, }); }); it('returns a collator with the correct type', async () => { const factory = BlogPostsCollatorFactory.fromConfig( mockServices.rootConfig({ data: { blogPosts: { baseUrl: 'http://localhost' } }, }), { logger: mockServices.logger.mock() }, ); expect(factory.type).toBe('blog-posts'); }); it('runs the collator and returns documents', async () => { const factory = BlogPostsCollatorFactory.fromConfig( mockServices.rootConfig({ data: { blogPosts: { baseUrl: 'http://localhost' } }, }), { logger: mockServices.logger.mock() }, ); const collator = await factory.getCollator(); const { error, documents } = await TestPipeline.fromCollator( collator, ).execute(); expect(error).toBeUndefined(); expect(documents).toHaveLength(2); expect(documents[0]).toMatchObject({ title: 'Getting Started', text: 'Welcome to our engineering blog', location: '/blog-posts/1', }); });});
스케줄 구성하기
생성된 모듈은 app-config.yaml에서 선택적 스케줄을 읽어요. 구성이 없으면 콜레이터는 10분마다 실행돼요. 스케줄을 사용자 지정하려면:
app-config.yaml
search: collators: blogPosts: schedule: # same options as in SchedulerServiceTaskScheduleDefinition # supports cron, ISO duration, "human duration" as used in code initialDelay: { seconds: 90 } # supports cron, ISO duration, "human duration" as used in code frequency: { hours: 6 } # supports ISO duration, "human duration" as used in code timeout: { minutes: 3 }
검색 결과 표시 사용자 지정하기
사용자 지정 콜레이터의 검색 결과는 기본 결과 목록 항목을 사용해 자동으로 표시돼요. 결과 표시 방식을 사용자 지정하려면 검색 플러그인을 사용자 지정 결과 목록 항목으로 확장하는 프론트엔드 모듈을 만들어요.
프론트엔드 모듈 스캐폴딩하기
Backstage 루트 디렉터리에서 다음 명령을 실행해요.
Backstage 루트 디렉터리에서
yarn new --select frontend-plugin-module
프롬프트가 나타나면 플러그인 ID에 search, 모듈 ID에 blog-posts를 입력해요. 템플릿은 plugins/search-module-blog-posts/에 새 패키지를 만들고 packages/app/package.json에 의존성으로 추가해요. 새 프론트엔드 시스템은 그곳에서 모듈을 자동으로 발견해요.
결과 목록 항목 컴포넌트 만들기
단일 검색 결과를 렌더링하는 컴포넌트를 추가해요. 각 결과는 콜레이터의 title, text, location 필드를 포함해요.
plugins/search-module-blog-posts/src/components/BlogPostSearchResultListItem.tsx
import { Link } from '@backstage/core-components';import ListItemIcon from '@material-ui/core/ListItemIcon';import ListItemText from '@material-ui/core/ListItemText';import { SearchDocument } from '@backstage/plugin-search-common';import { ReactNode } from 'react';export interface BlogPostSearchResultListItemProps { icon?: ReactNode; result?: SearchDocument; rank?: number;}export function BlogPostSearchResultListItem( props: BlogPostSearchResultListItemProps,) { const { icon, result } = props; if (!result) return null; return ( <> {icon && <ListItemIcon>{icon}</ListItemIcon>} <ListItemText primaryTypographyProps={{ variant: 'h6' }} primary={ <Link noTrack to={result.location}> {result.title} </Link> } secondary={result.text} /> </> );}
결과 목록 항목 등록하기
생성된 src/module.tsx를 업데이트해 콜레이터의 결과와 일치하는 predicate를 가진 SearchResultListItemBlueprint를 등록해요. predicate는 결과 type 필드를 확인하며, 이 필드는 콜레이터 팩토리에 설정된 type 속성과 일치해야 해요. 이 예시에서는 blog-posts예요.
plugins/search-module-blog-posts/src/module.tsx
import { createFrontendModule } from '@backstage/frontend-plugin-api';import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha';const blogPostSearchResultListItem = SearchResultListItemBlueprint.make({ name: 'blog-posts', params: { predicate: result => result.type === 'blog-posts', component: () => import('./components/BlogPostSearchResultListItem').then( m => m.BlogPostSearchResultListItem, ), },});export const searchModuleBlogPosts = createFrontendModule({ pluginId: 'search', extensions: [blogPostSearchResultListItem],});