⚔️

NestJSでStrategyパターンを使ったCSVダウンロード機能を実装する

2024/12/23に公開

はじめに

前回の記事でNestJSについて触れましたが、今回はより実践的な記事を書いてみました。

前回の記事はこちら。

APIでCSV出力する実装は良くあるかと思いますが、
出力形式をStrategyパターンで出し分ける実装をしてみました。
ライブラリはfast-csvを使用しました。

Strategyパターンについて

ChatGPTに聞いてみたら下記回答が返ってきました。

Strategyパターン(ストラテジーパターン)は、アルゴリズムや処理手順の「族(ファミリー)」をカプセル化し、実行時にそれらを切り替えられるようにするデザインパターンです。これにより、利用側のコードは特定の処理手順に依存せず、異なる戦略(処理方法)を容易に差し替えることができます。結果として、コードの変更に柔軟性が増し、拡張性・保守性が向上します。

似たようなロジック(アルゴリズム)を予め同一のインターフェイスを実装したクラスとして定義することで呼び出し側からはシンプルに呼べるかつ拡張しやすくなるというのが私の認識です。

実装内容

基底クラス

インターフェイスと抽象クラスの定義になります。
ジェネリクスを使用しているのはデータを取得する際に具象クラス側で型を決定できるようにするためになります。

import { Response } from 'express'
import { format } from '@fast-csv/format'

export interface ICsvStrategy<T> {
  getHeader(): string[]
  formatData(data: T[]): string[][]
  getFileName(): string
  generateCsv(response: Response): void
  fetchData(): AsyncGenerator<T[]>
}

export abstract class BaseCsvStrategy<T> implements ICsvStrategy<T> {
  abstract getHeader(): string[]
  abstract formatData(data: T[]): string[][]
  abstract getFileName(): string
  abstract fetchData(): AsyncGenerator<T[]>

  async generateCsv(response: Response): Promise<void> {
    response.writeHead(200, {
      'Content-Type': 'text/csv',
      'Content-Disposition': `attachment; filename="${this.getFileName()}"`,
    })
    const csvStream = format({ headers: this.getHeader() })
    csvStream.pipe(response)

    for await (const row of this.fetchData()) {
      const formattedRows = this.formatData(row)
      formattedRows.forEach((row) => {
        csvStream.write(row)
      })
    }
    csvStream.end()
  }
}

具象クラス

抽象クラスを継承した具象クラスになります。パターンに応じてこのクラスが増えていくような形になります。
Testという型をジェネリクスで指定していますがこちらはPrismaオグジェクトの型になります。
(名前がよくないですね。。)
fetchDataをジェネレーター関数にしているのはメモリ効率を意識して1度の読み込みで全てのデータを取得するのではなく10件ずつ読み込みレスポンスに書き込むためです。

import { Test } from 'prisma/prisma-client'
import { BaseCsvStrategy } from './base-csv-strategy'
import { TestRepository } from 'src/middleware/repositories/test.repository'
import { Injectable } from '@nestjs/common'

@Injectable()
export class ATypeCsvStrategy extends BaseCsvStrategy<Test> {
  private readonly headerMapping = [
    { key: 'id', label: 'ID' },
    { key: 'name', label: '名前' },
    { key: 'email', label: 'メールアドレス' },
    { key: 'description', label: '説明' },
    { key: 'createdAt', label: '作成日時' },
  ]

  constructor(private readonly testRepository: TestRepository) {
    super()
  }

  getFileName(): string {
    return 'a-type.csv'
  }

  getHeader(): string[] {
    return this.headerMapping.map(({ label }) => label)
  }

  formatData(data: Test[]): string[][] {
    const formattedData = data.map(({ id, name, email, description, createdAt }) => ({
      id,
      name,
      email,
      description,
      createdAt: createdAt
        .toLocaleString('ja-JP', {
          year: 'numeric',
          month: '2-digit',
          day: '2-digit',
          hour: '2-digit',
          minute: '2-digit',
          second: '2-digit',
          hour12: false,
        })
        .replace(/\//g, '-'),
    }))
    return formattedData.map((item) => this.headerMapping.map(({ key }) => item[key]))
  }

  async *fetchData(): AsyncGenerator<Test[]> {
    for await (const chunk of this.testRepository.findAllChunk()) {
      yield chunk
    }
  }
}

Factory

ファクトリークラスを追加し、resolveCsvStrategyで事前にDIコンテナに登録されたインスタンスを出し分けるようにしてます。
Factoryと具象クラスはModuleとして外側から使用するので@Injectable()を指定してDIコンテナに登録しておきます。

import { BadRequestException, Injectable } from '@nestjs/common'
import { match } from 'ts-pattern'
import { CsvFormatType } from 'src/modules/test/dto.ts/test.dto'
import { ATypeCsvStrategy } from './a-type-csv-strategy'

@Injectable()
export class CsvStrategyFactory {
  constructor(private readonly aTypeCsvStrategy: ATypeCsvStrategy) {}

  resolveCsvStrategy(formatType: CsvFormatType) {
    return match(formatType)
      .with(CsvFormatType.A_TYPE, () => this.aTypeCsvStrategy)
      .otherwise(() => {
        throw new BadRequestException('Invalid format type')
      })
  }
}

CsvModule

上記で作成したCsvStrategyFactoryとATypeCsvStrategyをProviderとして登録し、
外部から呼び出せるようにModuleを実装します。

import { Module } from '@nestjs/common'
import { ATypeCsvStrategy } from './a-type-csv-strategy'
import { CsvStrategyFactory } from './csv-strategy.factory'
import { TestRepository } from 'src/middleware/repositories/test.repository'
import { PrismaModule } from '../prisma/prisma.module'

@Module({
  imports: [PrismaModule],
  providers: [CsvStrategyFactory, ATypeCsvStrategy, TestRepository],
  exports: [CsvStrategyFactory],
})
export class CsvModule {}

ServiceとController

ServiceクラスではCsvStrategyFactory経由でインスタンスを取得してgenerateCsvを実行してcsvを出力させます。

export class TestService {
  constructor(
    private readonly testRepository: TestRepository,
    private readonly sqsService: SqsService,
    private readonly configService: ConfigService,
    private readonly redisService: RedisService,
    private readonly csvStrategyFactory: CsvStrategyFactory,
  ) {}

  async downloadCsv(request: DownloadCsvRequestDto, response: Response) {
    const formatType = request.formatType
    const strategy = this.csvStrategyFactory.resolveCsvStrategy(formatType)
    await strategy.generateCsv(response)
  }

Controllerではフォーマットタイプをリクエストで受け取りServiceクラスのdownloadCsvを呼び出すのみになります。

  @ApiOperation({
    operationId: 'test-download-csv',
    summary: 'テストCSVダウンロード',
    description: 'テストCSVダウンロードAPIの説明',
  })
  @ApiProduces('text/csv')
  @Post('download-csv')
  @HttpCode(HttpStatus.OK)
  async downloadCsv(@Body() body: DownloadCsvRequestDto, @Res() response: Response): Promise<void> {
    await this.testService.downloadCsv(body, response)
  }

テストコードについて

jestにて下記のように実装しました。
fast-csvをテスト環境にて実行して出力をシュミレートするのは避けモック化しております。
完全なテストにはなりませんがmockCsvStream.writeメソッドの呼び出しの引数で値の検証もしています。
※データは予め登録されている前提です。

describe('downloadCsv', () => {
    it('should download csv', async () => {
      const mockCsvStream = {
        pipe: jest.fn().mockReturnThis(),
        write: jest.fn().mockReturnThis(),
        end: jest.fn().mockReturnThis(),
      } as unknown as CsvFormatterStream<any, any>

      jest.spyOn(fastCsv, 'format').mockReturnValue(mockCsvStream)

      const mockResponse = {
        writeHead: jest.fn(),
        write: jest.fn(),
      } as unknown as Response

      const request: DownloadCsvRequestDto = {
        formatType: CsvFormatType.A_TYPE,
      }
      await service.downloadCsv(request, mockResponse)
      const expected = await prismaService.test.findFirst()

      expect(mockResponse.writeHead).toHaveBeenCalledWith(200, {
        'Content-Type': 'text/csv',
        'Content-Disposition': `attachment; filename="${'a-type.csv'}"`,
      })
      expect(mockCsvStream.pipe).toHaveBeenCalled()
      expect(mockCsvStream.write).toHaveBeenCalledWith([
        expected.id,
        expected.name,
        expected.email,
        expected.description,
        expected.createdAt
          .toLocaleString('ja-JP', {
            year: 'numeric',
            month: '2-digit',
            day: '2-digit',
            hour: '2-digit',
            minute: '2-digit',
            second: '2-digit',
            hour12: false,
          })
          .replace(/\//g, '-'),
      ])
      expect(mockCsvStream.end).toHaveBeenCalled()
    })
  })

最後に

NestJSのDI(依存性の注入)とStrategyパターンのカプセル化の合わせ技でCSV出力を実装してみました。この記事を読んで参考して頂けたら幸いです。

今回のリポジトリはこちらになります

Discussion