Nestで大量のCSVファイルからデータを取得しそれを元にデータを作る

この記事は大量のcsvファイルを高速で捌く方法！みたいな記事ではありませんのであしからず

前回作ったAPI`GET /match-data/win-rate`の続きを作っていきます。
今回のアプリケーションは戦績データを閲覧できるWebアプリケーションですのでまずは勝率を取得できるようにしましょう

データについては私が以前作成した04環境オンラインの戦績データを扱います。04環境オンラインについての説明は省きますがよろしければ遊んでみてください
[04環境オンライン](http://04kankyou-online-aplication.s3-website-ap-northeast-1.amazonaws.com/)

戦績データについてですがリリース直前になって戦績データを収集しようと実装したためDBなどを使わずにcsv形式で保存していて大変厄介なことになっています。
csvの中身についてはカラム数が大変な事になっています。

## CSVの内容
以下がcsvのカラムとその説明です。
`matchNumber`マッチカウント数 
`firstPlayer`先行プレイヤーのsocketID、先攻か後攻かを識別する意味しかない
`firstPlayer_isMobile`先行プレイヤーのクライアント。一応取ったけど意味はあるのか？
`firstPlayerLP`データ取得時の先行プレイヤーのライフポイント
`secondPlayer`後攻プレイヤーのsocketID
`secondPlayer_isMobile`後攻プレイヤーのクライアント。
`secondPlayerLP`後攻プレイヤーのライフポイント
`winner`勝者のsocketID
`cause`勝因(ライフポイントが0になるかデッキ切れか)
`turn_count`データ取得時のターン数
`date`データ取得時のタイムスタンプ
`firstPlayer_PotOfGreed`データ取得時までに先行プレイヤーが使った強欲な壺の回数
`firstPlayer_theForcefulSentry`使った強引な番兵の数
`firstPlayer_confiscation`押収の数
`firstPlayer_PainfulChoice`苦渋の選択
`firstPlayer_changeOfHeart`心変わり
`firstPlayer_snatchSteal`強奪
`firstPlayer_magicianOfFaith`聖なる魔術師がリバース効果を発動した回数
`firstPlayer_donZaloog_hand`首領ザルーグのランダムに手札を一枚捨てる効果を発動した回数
`firstPlayer_spiritReaper`魂を削る死霊のランダムに手札を一枚捨てる効果を発動した回数
`firstPlayer_injectionFairyLily`お注射天使リリー
`firstPlayer_fiberJar`ファイバーポッド
`firstPlayer_airknightParshath`天空騎士パーシアスの効果発動回数
`firstPlayer_airknightParshath_summon`天空騎士パーシアスの召喚回数
`firstPlayer_Jinzo_summon`人造人間サイコ・ショッカーの召喚回数
`firstPlayer_blackLusterSoldier_summon`カオス・ソルジャー開闢の使者の召喚回数
`firstPlayer_monster_length`データ取得時の先攻プレイヤーのモンスターゾーンにいるモンスターの数
`firstPlayer_monster1`モンスターゾーン1にあるモンスター
`firstPlayer_monster2`モンスターゾーン2にあるモンスター
`firstPlayer_monster3`モンスターゾーン3にあるモンスター
`firstPlayer_monster4`モンスターゾーン4にあるモンスター
`firstPlayer_monster5`モンスターゾーン5にあるモンスター
`firstPlayer_spell_trap_length`先攻プレイヤーの魔法罠ゾーンにある魔法罠カードの数
`firstPlayer_spell_trap1`魔法罠ゾーン1にあるカード
`firstPlayer_spell_trap2`魔法罠ゾーン2にあるカード
`firstPlayer_spell_trap3`魔法罠ゾーン3にあるカード
`firstPlayer_spell_trap4`魔法罠ゾーン4にあるカード
`firstPlayer_spell_trap5`魔法罠ゾーン5にあるカード
`firstPlayer_hand_length`先攻プレイヤーの手札の数
`firstPlayer_hand_json`先攻プレイヤーの手札の内容(パイプ`|`での区切り)
`firstPlayer_grave_length`先攻プレイヤーの墓地の数
`firstPlayer_grave_json`先攻プレイヤーの墓地の内容(パイプ`|`での区切り)
`firstPlayer_banish_length`先攻プレイヤーの除外ゾーンの数
`firstPlayer_banish_json`先攻プレイヤーの除外ゾーンの内容(パイプ`|`での区切り)
`secondPlayer_PotOfGreed`後攻プレイヤーが使った強欲な壺の回数
・
・
・
といったように**カラムが100個以上**あります。今回は本当に`winner`のところだけ見る感じにしますのであまり関係ありませんが。

`04-stats`のためにDBを用意した方がいいのはわかっているのでDBの構成を考えている所です。
データの一意性が無く、タイムスタンプを一意な値として使うしかなさそうなのがネックです。マイグレーションとかちゃんとできるのかな・・・といった感じです。

話が少し逸れましたがとにかく今回は一旦CSVを元に勝率データを算出してAPIのレスポンスとして返す事を行います。多分DBで管理するようになると思うのでその際に変更があるので変更しやすいような設計を心がけていきましょう。

あと04環境オンラインのリリース時に戦績データがちゃんと入ってきているか確認したかった(どれだけの人がプレイするかわからなかった)ので10件で1ファイルにしました。ありがたいこと多くの人に遊んでいただいたおかげで執筆時点で3560件のデータがあります。つまり**356ファイル**あります。


## dto
``` win-ratio.dto.ts
import { ApiProperty } from '@nestjs/swagger';

export class WinRatio {
    @ApiProperty({ example: 1000, description: '全体のマッチ数' })
    match_count: number;

    @ApiProperty({ example: 600, description: '先手の勝数' })
    first_player_win: number;

    @ApiProperty({ example: 400, description: '後手の勝数' })
    second_player_win: number;

    @ApiProperty({ example: 100, description: '引き分けの数' })
    draw: number;
}
```

こんな感じです。正直見ればだいたい分かると思いますが、その時点のマッチ数と先攻、後攻の勝利数。引き分けの数です。
これをレスポンスの返り値型として指定します。

今回はグラフになると面白いかな～と考えているのでその時点のマッチ数もdtoに含めています。
dto名などを`winrate`から`winratio`に変えてます。前回の記事と多少の違いがありますがご了承ください

コントローラーは前回も書きましたが再度記しておきます。

## コントローラー

``` match-data.controller.ts

import { Controller, Get } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { MatchDataService } from 'feature/match-data/match-data.service';
import { WinRatio } from 'feature/match-data/win-ratio.dto';

@Controller('match-data')
export class MatchDataController {
  constructor(private readonly matchDataService: MatchDataService) {}

  @Get('/win-ratio')
  @ApiOperation({ summary: '勝率データを取得します' })
  @ApiResponse({ status: 200, description: '勝率データが返却されました' })
  @ApiOkResponse({ description: '勝率のデータ', type: [WinRatio] })
  async getWinRate(): Promise<WinRatio[]> {
    return this.matchDataService.getWinRatios();
  }
}
```

## サービス
``` match-data.service.ts

@Injectable()
export class MatchDataService {
  constructor(private readonly matchDataRepository: MatchDataRepository) {}

  async getWinRatios(): Promise<WinRatio[]> {
    const winRatioData = await this.matchDataRepository.getWinRatiosfromS3();

    return winRatioData;
  }
}
```

### `@Injectable()`
依存性注入の管理を行ってくれます。
`依存性注入（Dependency Injection）`の`Injectable`です。名前通り注入ができるようになります。無いと他の場所からインスタンスの作成が出来ない(はず)なので忘れずにつけましょう。

``` match-data.service.ts
  async getWinRatios(): Promise<WinRatio[]> {
    const winRatioData = await this.matchDataRepository.getWinRatiosfromS3();

    return winRatioData;
  }
```

リポジトリ側の実際に処理する関数`getWinRatiosfromS3`を呼び出します。この関数はDBに移行するときには完全に無くなると思います。

## リポジトリ

``` match-data.repository.ts
import { Injectable } from '@nestjs/common';
import { WinRatio } from './win-ratio.dto';
import * as AWS from 'aws-sdk';
import * as csv from 'csv-parser';

type S3WinRatioData = {
  firstPlayer: string;
  secondPlayer: string;
  winner: string;
};
@Injectable()
export class MatchDataRepository {
  private readonly s3 = new AWS.S3();
  private readonly bucketName = '04-matchdata';

  constructor() {
    AWS.config.update({
      accessKeyId: process.env.AWS_ACCESS_KEY_ID,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    });
    this.s3 = new AWS.S3();
  }

  async getWinRatiosfromS3(): Promise<WinRatio[]> {
    const winRatios: S3WinRatioData[] = [];
    const files = await this.listFilesInS3Bucket();

    for (const file of files) {
      const fileWinRatios = await this.parseCsvFromS3(file);
      winRatios.push(...fileWinRatios);
    }

    return this.aggregateWinRatios(winRatios);
  }

  private async listFilesInS3Bucket(): Promise<string[]> {
    const params = {
      Bucket: this.bucketName,
    };

    const data = await this.s3.listObjectsV2(params).promise();
    if (!data.Contents) {
      return [];
    }
    return data.Contents.map((item) => item.Key).filter(
      (key): key is string => key !== undefined,
    );
  }

  private async parseCsvFromS3(key: string): Promise<S3WinRatioData[]> {
    const winRatios: S3WinRatioData[] = [];
    const params = {
      Bucket: this.bucketName,
      Key: key,
    };

    return new Promise((resolve, reject) => {
      this.s3
        .getObject(params)
        .createReadStream()
        .pipe(csv())
        .on('data', (data) => {
          const winRatio: S3WinRatioData = {
            firstPlayer: data.firstPlayer,
            secondPlayer: data.secondPlayer,
            winner: data.winner,
          };
          winRatios.push(winRatio);
        })
        .on('end', () => resolve(winRatios))
        .on('error', (error) => reject(error));
    });
  }

  private aggregateWinRatios(winRatios: S3WinRatioData[]): WinRatio[] {
    let matchCount = 0;
    let firstPlayerWin = 0;
    let secondPlayerWin = 0;
    let draw = 0;

    const data = winRatios.map((winRatio) => {
      matchCount++;
      if (winRatio.winner === winRatio.firstPlayer) {
        firstPlayerWin++;
      } else if (winRatio.winner === winRatio.secondPlayer) {
        secondPlayerWin++;
      } else {
        draw++;
      }
      return {
        match_count: matchCount,
        first_player_win: firstPlayerWin,
        second_player_win: secondPlayerWin,
        draw: draw,
      };
    });
    return data;
  }
}

```
上記のコードを見ていきましょう。

``` match-data.repository.ts
type S3WinRatioData = {
  firstPlayer: string;
  secondPlayer: string;
  winner: string;
};
```
一旦csvから取得するための型を取ります。先攻プレイヤーのID、後攻プレイヤーのID、勝ったプレイヤーのIDです。
IDはsocketのIDですので勝敗を調べる以外に意味はないです。連続してプレイしてたらIDが同じはずなので最大連勝数とか連続プレイ数とか見ても面白いかもしれませんね。


``` match-data.repository.ts
 private readonly s3 = new AWS.S3();
 private readonly bucketName = '04-matchdata';
```

s3の設定です。正確にはAWS SDK(Software Development Kit)でs3にアクセスする設定です。他のサービスも設定できるので他のサービスを使うときにまたお話するかもしれません。
今回はs3を使って`04-matchdata`というバケット(データを入れておく保管場所)の名前を指定します。


``` match-data.repository.ts
  constructor() {
    AWS.config.update({
      accessKeyId: process.env.AWS_ACCESS_KEY_ID,
      secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
    });
    this.s3 = new AWS.S3();
  }
```

### `AWS.config.update`

AWS SDKの設定の更新です。アクセスキーとシークレットアクセスキーが必要です。このタイミングで`.env`ファイルについてもお話しましょう

``` .env
AWS_ACCESS_KEY_ID = "**********************"
AWS_SECRET_ACCESS_KEY = "****************************"
```
dotenvのライブラリを入れるとここで設定した値を環境変数として使えます。`.env`ファイルに大事なキーとかをまとめておいて`.gitignore`で無視しておきましょう。githubにアクセスキー乗せたせいでAWSから100万とか請求来たって話も聞いたことあるので。

今回はこれらのキーについては04環境のを使っていますが良くないので後ほど変えましょう。アプリケーションが形になったらちゃんとIAMとかアクセスキーとかを04stats用の物を用意します...


### ` this.s3 = new AWS.S3();`
認証の設定を更新したら再度s3クライアントのインスタンスを作成し初期化しましょう。


``` match-data.repository.ts

  async getWinRatiosfromS3(): Promise<WinRatio[]> {
    const winRatios: S3WinRatioData[] = [];
    const files = await this.listFilesInS3Bucket();

    for (const file of files) {
      const fileWinRatios = await this.parseCsvFromS3(file);
      winRatios.push(...fileWinRatios);
    }

    return this.aggregateWinRatios(winRatios);
  }

  private async listFilesInS3Bucket(): Promise<string[]> {
    const params = {
      Bucket: this.bucketName,
    };

    const data = await this.s3.listObjectsV2(params).promise();
    if (!data.Contents) {
      return [];
    }
    return data.Contents.map((item) => item.Key).filter(
      (key): key is string => key !== undefined,
    );
  }
```

それではメインの部分の解説に移ります
### `const files = await this.listFilesInS3Bucket();`

``` match-data.repository.ts
  private async listFilesInS3Bucket(): Promise<string[]> {
    const params = {
      Bucket: this.bucketName,
    };

    const data = await this.s3.listObjectsV2(params).promise();
    if (!data.Contents) {
      return [];
    }
    return data.Contents.map((item) => item.Key).filter(
      (key): key is string => key !== undefined,
    );
  }
``` 

#### `const data = await this.s3.listObjectsV2(params).promise();`

`listObjectsV2`でバケット(04-matchdata)内のファイルリストを取得します。今回はファイル名だけに加工します。

``` match-data.repository.ts
if (!data.Contents) {
  return [];
}
return data.Contents.map((item) => item.Key).filter(
  (key): key is string => key !== undefined,
);
```

ここで取ってきたデータをフィルタリングします。
`Contents.Key`がファイル名なのでundefinedじゃないかをチェックします。`listFilesInS3Bucket`は以上です。


``` match-data.repository.ts
for (const file of files) {
  const fileWinRatios = await this.parseCsvFromS3(file);
  winRatios.push(...fileWinRatios);
}
```

取得したファイル名でループで回しながら`S3WinRatioData`型の配列である`winRatios`に入れていきます。
`parseCsvFromS3`で変換というか必要な情報の抜き出し処理をします。


``` match-data.repository.ts
  private async parseCsvFromS3(key: string): Promise<S3WinRatioData[]> {
    const winRatios: S3WinRatioData[] = [];
    const params = {
      Bucket: this.bucketName,
      Key: key,
    };

    return new Promise((resolve, reject) => {
      this.s3
        .getObject(params)
        .createReadStream()
        .pipe(csv())
        .on('data', (data) => {
          const winRatio: S3WinRatioData = {
            firstPlayer: data.firstPlayer,
            secondPlayer: data.secondPlayer,
            winner: data.winner,
          };
          winRatios.push(winRatio);
        })
        .on('end', () => resolve(winRatios))
        .on('error', (error) => reject(error));
    });
  }

```

### `this.s3.getObject(params).createReadStream()`

`this.s3.getObject(params)`で指定された`param`(今回でいうとバケット名`04-matchdata`)からオブジェクトを取得します。

`createReadStream()`メソッドで取得したオブジェクトの読み取りストリームを作成します。

### `.pipe(csv())`
上記のストリームからcsvとして解析してくれます。

``` match-data.repository.ts
.on('data', (data) => {
    const winRatio: S3WinRatioData = {
    firstPlayer: data.firstPlayer,
    secondPlayer: data.secondPlayer,
    winner: data.winner,
    };
    winRatios.push(winRatio);
})
```

`.on`のイベントハンドラで`data`イベント(データを読み取るとdataイベントが呼ばれる)のイベントでデータ処理の部分を書きます。
なんか`socket.on`と似たような感じみたいですね。

``` match-data.repository.ts
  const winRatio: S3WinRatioData = {
    firstPlayer: data.firstPlayer,
    secondPlayer: data.secondPlayer,
    winner: data.winner,
  };
  winRatios.push(winRatio);
  ```
`csv-parser`君は賢いので1行目をヘッダー情報として理解してくれるしヘッダー名でデータを取れるのでこんな感じにしてデータを抜き出せます。他のデータも欲しくなったらここに書き足すことになりますね。

``` match-data.repository.ts
  .on('end', () => resolve(winRatios))
  .on('error', (error) => reject(error));
```
`data`のイベントハンドラと同じでこれは終わったときとエラーになったときのイベントハンドラです。終わったときは1ファイル分のデータをresolveしてもらいましょう。


``` match-data.repository.ts
  private aggregateWinRatios(winRatios: S3WinRatioData[]): WinRatio[] {
    let matchCount = 0;
    let firstPlayerWin = 0;
    let secondPlayerWin = 0;
    let draw = 0;

    const data = winRatios.map((winRatio) => {
      matchCount++;
      if (winRatio.winner === winRatio.firstPlayer) {
        firstPlayerWin++;
      } else if (winRatio.winner === winRatio.secondPlayer) {
        secondPlayerWin++;
      } else {
        draw++;
      }
      return {
        match_count: matchCount,
        first_player_win: firstPlayerWin,
        second_player_win: secondPlayerWin,
        draw: draw,
      };
    });
    return data;
  }
  ```
特に言うことはないです。
今回は勝率の遷移を見たいので`match_count`として試合数も取ってます。

以上で実装が完了したので実際にswaggerでデータが取れるか確認してみましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3946470/e5cfa75f-de50-d9ac-06be-451aaf39de64.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3946470/b1b3bdeb-b35e-94fe-9f7d-64467ae3fe6f.png)

取得が完了しました！ちゃんと3560件取れています！

ちなみにですがレスポンスが返ってくるまでの時間は30秒でした。Webアプリケーションとしてレスポンスに30秒かかるのはサービスとしてあまりにも酷いです。次回はフロント側の実装を行いますがその次はレスポンスというかデータ取得にかける時間を短くするためにDB移行をしようと思います。