電通総研 テックブログ

電通総研が運営する技術ブログ

S3にあるファイルをExcelJSで加工してクライアントに返す方法

X(クロス)イノベーション本部 ソフトウェアデザインセンター セキュリティグループの柴田です。
これは電通国際情報サービス Advent Calendar 2022 6日目の記事です。5日目の昨日は、もう一人の柴田さんの記事「Argo CDを使ってIstioをバージョンアップする」でした。

はじめに

社内でExcelファイルを扱う業務があり、特定のExcelファイルをテンプレートとして都度ファイルをコピーする形で運用しています。

Excelファイルはメンテナンス時の差分管理がExcel単体の機能では難しく、複数人での修正も可能ですが、個人的には変更履歴があまり視認性の良いものではないと考えています。

そこで、Excelファイルに記載する内容を別途テキストベースで差分管理し、修正したテキストをExcelファイルに流し込むシステムを構築することになりました。

また、併せてNext.jsを利用しWebアプリケーション化も実現して、Excelファイルに追加するテキストもWebアプリケーション上から入力できるような仕組みにしています。

今回作成しているWebアプリケーションは、Next.jsとTypeScriptを利用しています。 その中で、Amazon S3に保存したExcelファイル(拡張子xlsx)を取得して、Excelファイルを加工処理してからクライアント(Webブラウザ)に返却する、という処理を紹介します。

Webアプリケーションの構成

前述の通り、Amazon S3を利用しているため、WebシステムはAWS上に構築しています。
実際はもう少し複雑なシステム構成ですが、以下は、今回紹介するExcel処理部分のフローを抜粋した図です。

Webブラウザからリクエストを受けたWebアプリケーション(Next.js)が、バックエンド側でAmazon S3からExcelファイルを取得します。取得したExcelファイルの加工処理をした後、Webブラウザ側に返却する流れです。

Amazon S3からのデータ取得のライブラリはAWS SDK for JavaScript v3Excelファイルの加工のライブラリはExcelJSを利用しています。

動作イメージ

ダウンロードボタンを押すと、/api/downloadにリクエストが送信され、加工したファイルがダウンロードされます。

コードサンプル

Next.jsのプロジェクト作成は、以下の陳さんの記事を参考にしました。 tech.isid.co.jp

まずはフロント側です。

// pages/index.tsx
import styles from '../styles/Home.module.css'

const Home = () => {
  const download = async (fileName: string) => {
    const response = await fetch(`/api/download`, { method: 'GET' })

    try {
      if (response.status !== 200) {
        throw new Error()
      } else {
        const blob = await response.blob()
        const blobFile = new Blob([blob], { type: 'application/xlsx' })

        const a = document.createElement('a')
        a.style.display = 'none'
        document.body.appendChild(a)
        const url = window.URL.createObjectURL(blobFile)
        a.href = url
        a.download = fileName
        a.click()
        window.URL.revokeObjectURL(url)
      }
    } catch (e: unknown) {
      console.error(e)
    }
  }

  return (
    <div className={styles.container}>
      <main className={styles.main}>
        <button onClick={() => download('ダウンロードファイル名.xlsx')}>ダウンロード</button>
      </main>
    </div>
  )
}

export default Home

サンプルコードでは、ボタンをクリックするとバックエンドのAPIにリクエストし、レスポンスを組み立てて、ファイル保存のダイアログを表示させる形にしています。

次にAPIです。

// pages/api/download.ts
import { Readable } from 'stream'
import * as ExcelJS from 'exceljs'
import type { NextApiRequest, NextApiResponse } from 'next'
import { getObject } from '../../src/aws/s3'

export default async function download(req: NextApiRequest, res: NextApiResponse): Promise<void> {
  try {
    const s3Output = await getObject(
      'bucket',
      'key'
    )

    if (s3Output.Body instanceof Readable) {
      const s3OutputStream = s3Output.Body as Readable

      res.setHeader('Content-Type', 'application/xlsx')
      res.status(200)

      const workbook = new ExcelJS.Workbook()
      await workbook.xlsx.read(s3OutputStream)
      const sheet = workbook.getWorksheet('Sheet1')
      sheet.getRow(1).getCell(1).value = 'テスト'

      await workbook.xlsx.write(res)
    }
    return
  } catch (e: unknown) {
    return res.status(500).json({ message: 'Internal Server Error' })
  }
}
// src/aws/s3.ts
import { GetObjectCommand, GetObjectCommandOutput, S3Client } from '@aws-sdk/client-s3'

export async function getObject(bucket: string, key: string): Promise<GetObjectCommandOutput> {
  const params = {
    Bucket: bucket,
    Key: key,
  }
  const command = new GetObjectCommand(params)
  return await new S3Client({ region: 'ap-northeast-1' }).send(command)
}

Amazon S3にリクエストを送信し、レスポンスを受け取ります。
受け取ったStreamを順次処理するため、そのままExcelJSに渡しています。
ExcelJSでは、Streamをそのまま処理してくれます。

参考 github.com

ExcelJSのソースコードを見ると、実際はメモリ上に一旦すべて展開されます。
Streamのまま順次処理していく機能も用意されていますが、今回はファイルサイズが小さく、処理が複雑になりそうだったため利用しませんでした。

参考 github.com

最後にテストコードです。API Routesのテストライブラリはnext-test-api-route-handlerを利用しています。

// test/api/download.test.ts
import fs from 'fs'
import path from 'path'
import { PassThrough } from 'stream'
import { GetObjectCommandOutput } from '@aws-sdk/client-s3'
import { sdkStreamMixin } from '@aws-sdk/util-stream-node'
import * as ExcelJS from 'exceljs'
import { testApiHandler } from 'next-test-api-route-handler'

import download from '../../pages/api/download'
import * as s3Module from '../../src/aws/s3'

const handler: typeof download = download
const filePath = path.join(__dirname, 'download.xlsx')

const getObjectSpyOn = jest.spyOn(s3Module, 'getObject')

const getObject200 = async (): Promise<GetObjectCommandOutput> => {
  return {
    Body: sdkStreamMixin(fs.createReadStream(filePath)),
    $metadata: {
      httpStatusCode: 200,
    },
  }
}

const getObject500 = async (): Promise<GetObjectCommandOutput> => {
  const mockReadable = sdkStreamMixin(new PassThrough())
  mockReadable.emit('error', new Error('error'))

  return {
    Body: mockReadable,
    $metadata: {
      httpStatusCode: 500,
    },
  }
}

describe('client-s3', () => {
  test('200', async () => {
    getObjectSpyOn.mockImplementation(getObject200)

    await testApiHandler({
      handler,
      test: async ({ fetch }) => {
        const res = await fetch({ method: 'GET' })
        expect(res.status).toBe(200)

        const workbook = new ExcelJS.Workbook()
        await workbook.xlsx.read(res.body)
        const worksheet = workbook.getWorksheet('Sheet1')
        expect(worksheet.getRow(1).getCell(1).value).toBe('テスト')
      },
    })
  })

  test('500', async () => {
    getObjectSpyOn.mockImplementation(getObject500)

    await testApiHandler({
      handler,
      test: async ({ fetch }) => {
        const res = await fetch({ method: 'GET' })
        expect(res.status).toBe(500)
      },
    })
  })
})

Amazon S3から取得する箇所をモック化しています。Stream部分にはPassThroughを利用しています。

また、AWS SDK for JavaScript v3のv3.188.0以降では、Stream処理が一部変更になり、Bodyの型がSdkStream<Readable>で返るようになりました。
そのため、@aws-sdk/util-stream-nodeにあるsdkStreamMixinを利用して、テストコードのモックでも同じ型を返すようにしています。

参考 github.com github.com

以上、Next.jsとTypeScriptを利用して、Amazon S3に保存されているExcelをExcelJSで加工してクライアントに返す方法の紹介でした。

電通国際情報サービス Advent Calendar 2022 7日目の明日は、Faisalさんの記事です。お楽しみに!


私たちは同じチームで働いてくれる仲間を大募集しています!たくさんのご応募をお待ちしています。
- セキュリティエンジニア(セキュリティ設計)

執筆:@shibata.shunsuke、レビュー:Ishizawa Kento (@kent)Shodoで執筆されました