Nuxt.js + NestJS + TypeORMでCSVエクスポート機能を実装する

本記事は[うるる Advent Calendar 2019](https://adventar.org/calendars/4548) 13日目の記事です。

# はじめに

Webサービスを作っていると「データをCSVでエクスポートしたい」なんていう要求はよく出てくるのではないでしょうか。
かくいう私も必要にかられて実装をしたのでそれを書き残しておきます。
今回はタイトルからも分かる通り、DBからfetchしてきたデータをCSVにするまでの実装をコードも交えて解説していきたいと思います。

# 前提

以下のものがインストール and セットアップ済みであることが前提です。

- Nuxt.js : v2.10.2
- NestJS : v6.10.0
    - aws-sdk
    - csv-stringify
- ORM : TypeORM

## APIの実装

まずはAPIの実装からしていきます。
こんな感じで、CSVに加工される前のStreamを返すAPIを作成します。
こんな感じで、TypeORMからデータをストリームで取得して`csv-stringify`を用いてCSVの形に整形したあとで`Response`オブジェクトに渡しています。

```typescript:user.controller.ts
    @Controller('users')
    export class UserController {
      constructor(private readonly userService: UserService) {}
    
      @Get('csv')
      async getCsv(@Res() res: Response) {
        const strigifier = await this.userService.getCsvStream();
    
        res.setHeader('Content-Type', 'text/csv');
        return strigifier.pipe(res);
      }
    }

    // user.service.ts
    @Injectable()
    export class UserService {
      constructor(
        @InjectRepository(User) private readonly userRepository: Repository<User>,
      ) {}
    
      async getCsvStream(): Promise<Stringifier> {
        const qb = this.userRepository.createQueryBuilder('user');
        const stream = await qb.stream();
    
    		// csv-stringifyを初期化
        const stringifier = new Stringifier({
          header: true,
          columns: ['id', 'name', 'age'],
        });
    
        // レコードのデータが読み込まれた
        stream.on('result', res => {
          stringifier.write([res.user_id, res.user_name, res.user_age]);
        });
    
        // レコードの読み込みが終了したとき
        stream.on('end', () => {
          stringifier.end();
        });
    
        return stringifier;
      }
    }
```

# 余談

余談になるのですが、TypeORMの`stream()`を使用して作成されたReadable Streamsのイベントがややこしくてちょっと苦戦しました。

というのも、１行ごとにデータを取得しようとして、`data`イベントを待ち受けていたのですがいつまで経ってもデータが入って来ませんでした。調べてみると、どうやら`result`イベントなるものでデータを受け取るらしいことが判明したのです。

```typescript
    // NG
    stream.('data', () => {...}) 
    
    // OK
    stream.on('result', () => {...})
```

そもそも、TypeORMのドキュメントには`stream()`についてのexampleも掲載されていなかったので、偶然見つけた github の Issueがなかったら僕は途方に暮れていたことでしょう。

[https://github.com/typeorm/typeorm/issues/347](https://github.com/typeorm/typeorm/issues/347)

## 追記 2019/12/19

`result`イベントを待ち受けないとだめと書きましたが、↓のPRがマージされているようで、最新版(0.2.21以上？)のTypeORMをお使いの方は`data`イベントでないとすべてのデータを受け取ることができなくなっていました。
https://github.com/typeorm/typeorm/pull/5036

もともとのTypeORMの仕様は`stream()`呼んだ際に、`Readable`が返ってくると型が定義されているのですが、実際に返ってくるのは [mysqljs/mysql](https://github.com/mysqljs/mysql) の`Query`オブジェクトたっだようで、それを正しい実装に修正したようです。


# フロントの実装

APIもできたので、今度はフロントの実装をしていきたいと思います。

実装と言っても、今回はCSVをエクスポートする機能のみを実装すればいいので、コードはめっちゃシンプルです。(そもそもNuxt.jsを使う必要すらなかったのですが、使ってみたかったので使ってみました。)

全てはgetCsv()というメソッドがやってくれているのですが、中身でやっていることをざっくりと解説するとこんな感じです。

1. API叩く
2. `<a>`を生成
3. `<a>`の`href`にBlobから生成したURLを設置
4. クリックイベントを発生させ、ダウンロードをさせる。

```vuejs:default.vue
    <template>
      <v-app>
        <v-content>
          <v-container>
            <v-btn icon color="primary" @click="getCsv">
              <v-icon>mdi-cloud-download</v-icon>
            </v-btn>
          </v-container>
        </v-content>
      </v-app>
    </template>
    
    <script>
    export default {
      methods: {
        async getCsv() {
          const result = await this.$axios.get('/users/csv')
          const link = document.createElement('a')
          link.href = URL.createObjectURL(new Blob([result.data]), {
            type: 'text/csv'
          })
          link.download = 'task_list.csv'
          link.click()
        }
      }
    }
    </script>
```

# まとめ

今回の実装では、約3万レコードをCSVにエクスポートしてもダウンロードにかかる時間は約2秒ほどです。お手軽に実装できるので、ぜひ。

# おまけ

今回はデータの総量が少なかったために処理しきれるということで主にStreamを使用したCSVのエクスポート機能を実装してみました。
この実装方法では7カラムある約4万件のレコードまでは2-3秒でデータを取得することができるのですが、もっと大量のレコードがある場合はCPUの使用率などを考えると厳しいかもしれません。(まぁ、何十万件のレコードのデータが入ったCSVが必要とされるのか？という疑問はあると思いますが、、、)

そこで、DBからストリームで取得したデータを直接S3にアップロードして、そのリソースに対して署名付きURLを発行してダウンロードする。という実装を考えてみました。
先程の`user.service.ts`に追記して、それを`user.controller.ts`で呼び出すイメージですね。

```typescript:user.service.ts
  async getCsvFromS3Stream(filename: string): void {
    const stream = await this.userRepository
      .createQueryBuilder('user')
      .stream();

    const stringifier = new Stringifier({
      header: true,
      columns: ['id', 'name', 'age'],
    });
    // レコードが入ってきたとき
    stream.on('result', res => {
      stringifier.write([res.user_id, res.user_name, res.user_age]);
    });
    // レコードの読み込みが終了したとき
    stream.on('end', async () => {
      stringifier.end();

      const s3 = new S3();
      s3.upload({
        Bucket: process.env.CSV_S3_BUCKET,
        Key: filename,
        Body: stringifier,
      });
    });
  }
    
    // 署名付きURLの取得
  getPreSignedUrl(filename: string): Promise<string> {
    const s3 = new S3();

    return s3.getSignedUrlPromise('getObject', {
      Bucket: process.env.CSV_S3_BUCKET,
      Key: filename,
      Expires: 60,
    });
  }
```

```typescript:user.controller.ts
  @Get('csv-from-s3')
  async getCsvFromS3(): Promise<string> {
    const unixTime = new Date().getTime();
    const filename = `user_list_${unixTime}.csv`;

    await this.userService.getCsvFromS3Stream(filename);
    return this.userService.getPreSignedUrl(filename);
  }
```

僕も実装をしていて知ったのですが、aws-sdkを用いてS3にアップロードをする場合はStreamも使用することができます。Node.jsはデータをストリームとして読み込んだり書き込んだりすることが結構あるらしく「Streamを制するものはNode.jsを制す」らしいです。
Node.jsは非同期I/Oが特徴なのでそれを実装に落としこんだと言えるStreamを制することができればNode.jsを制したと言っても過言ではないのかもしれませんね！

# 参考にしたドキュメント

- Node.js v13.3.0 Documentation
    - [https://nodejs.org/api/stream.html](https://nodejs.org/api/stream.html)
- NestJS Documentation
    - [https://docs.nestjs.com/](https://docs.nestjs.com/)
- aws-sdk > Class: AWS.S3
    - [https://docs.aws.amazon.com/ja_jp/AWSJavaScriptSDK/latest/AWS/S3.html](https://docs.aws.amazon.com/ja_jp/AWSJavaScriptSDK/latest/AWS/S3.html)
- TypeORM > Streaming result data
    - [https://typeorm.io/#/select-query-builder/streaming-result-data](https://typeorm.io/#/select-query-builder/streaming-result-data)
- CSV stringifier for Node.js
    - [https://csv.js.org/stringify/api/](https://csv.js.org/stringify/api/)