Google DriveからGoogle Cloud Storageにファイルをコピーする

ピリカではアルバトロスプロジェクトで集めたマイクロプラスチックサンプルを分析する際、各サンプルの画像を撮っています。
このサンプル画像には、右下にスケールが書いてあり、この数字を元に映っているサンプルの最大径・面積などを求めています。
![20210907121435.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609129/e8bd4996-22f1-7c2a-95f5-8f7b6a6480ef.png)
※[アルバトロスオープンデータ](https://pirika.widgets.albatross.pirika.org/project2020/projects/tIHWEtitZ2DepK0xfDej/surveys/ImpV9ulFBkp1xy5yqwJg/samples/8)より © 2021 Pirika Association.

右下のスケールのテキストは顕微鏡のソフトウェアにより自動挿入されており、テキストデータとして得ることはできなかったため、別途このテキストを読み取る作業が必要ですが、1000を超すサンプルすべてに対してこれを人力でやるのは非効率です。
そこで、[Cloud Vision API](https://cloud.google.com/vision?hl=ja)を用いてこの右下のスケールを読み取るようにしました。

![20210907121718.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609129/b157690f-3f36-d41a-e44b-ab08d458b0d7.png)

サンプル画像は分析作業の段階ではGoogleドライブに入っていますが、Cloud Vision APIで分析するにはhttp/httpsでアクセスできるようにするか、Google Cloud Storageにコピーする必要があります。

GoogleドライブからGoogle Cloud Storageへのコピーする際、一度ダウンロードしてからアップロードをすると時間がかかるだけでなく、一時ファイルに保存する必要があり面倒です。
なるべく手間なく高速にできればと思い、TypeScriptでストリーミングコピーを実装してみたのでご紹介します。

## 事前準備

- GCPプロジェクトの「APIとライブラリ」でGoogle Drive APIとCloud Storageが使えるように設定しておく
- TypeScriptからGCPの認証を通せるように設定しておく(`gcloud auth application-default login`やサービスアカウントキー等)
- npm package のインストール (使用したバージョン)
    - @google-cloud/storage: 5.7.4
    - googleapis: 67.0.0

## コード

```typescript
import { Storage } from "@google-cloud/storage";
import { google } from "googleapis";
import { Readable } from "stream";

  public async copyToCloudStorageAsync(fileId: string, bucketName: string, storageFileName: string, contentType: string): Promise<void> {
    // 1: Google Driveのファイルを取得する(responseTypeでstreamを指定するのがポイント)
    const authClient = await google.auth.getClient({
      scopes: "https://www.googleapis.com/auth/drive"
    });
    const drive = google.drive({ version: "v3", auth: authClient });
    const media = await drive.files.get({ fileId, supportsTeamDrives: true, alt: "media" }, { responseType: "stream" });

    // 2: Google Cloud Storage のファイルを取得する
    const storage = new Storage();
    const bucket = storage.bucket(bucketName)
    const uploadFile = bucket.file(storageFileName);

    // 3: Promise でコピー処理全体を非同期にさせる
    await new Promise<void>((resolve, reject) => {
      // 4: Driveから読み取りストリーム、GCSから書き込みストリームを得る
      const downloadStream = media.data as Readable;
      const uploadStream = uploadFile.createWriteStream({
        metadata: {
          cacheControl: "no-cache",
          contentType
        }
      });
      // 5: pipeで読み取りストリームと書き込みストリームを繋ぐ
      downloadStream.pipe(uploadStream);
      // 6: イベントハンドラとPromiseのresolve/rejectを繋ぎこむ
      downloadStream.on("error", reject);
      uploadStream.on("error", reject);
      uploadStream.on("finish", resolve);
    });
  }
```

### 1: Google Driveのファイルを取得する

Google Driveのクライアントインスタンスを作成し、drive.files.get メソッドでGoogle Driveのファイルを取得します。

getメソッドの取得ファイルの種類で`alt: "media"`オプションに`responseType: "stream"`を指定することで、ファイルの内容をReadableのストリームの形で得ることができます。

### 2: Google Cloud Storageのファイルを取得する

書き込み先のCloud Storageのファイルを取得します。

### 3: Promiseでコピー処理全体を非同期にする

streamのイベントはonでコールバックするため、awaitで処理しやすいように全体をPromiseにしておきます。

### 4: Driveから読み取りストリーム、GCSから書き込みストリームを得る

Driveの戻り値はReadableになっていますが、TypeScriptの型は不定の状態になっているのでasキャストして使えるようにしておきます。

GCSの戻り値はfileオブジェクトなので、createWriteStreamを使って書き込み用のストリームを得ます。

### 5: pipeで読み取りストリームと書き込みストリームを繋ぐ

Driveの読み込みのストリームとGCSの書き込みストリームが揃ったので、pipeで繋ぎこんでデータをコピーします。

### 6: イベントハンドラとPromiseのresolve/rejectを繋ぎこむ

書き込みストリームが完了すれば処理が完了したとしてresolve、どちらかのストリームが失敗したらエラーとしてrejectしています[^1]。

[^1]: 実はこのコードでは不十分で、本当は[downloadStreamでerrorになった場合は、uploadStreamを明示的に閉じなければリソースリークに繋がります](https://solutionware.jp/blog/2019/11/19/node%E3%81%A7%E3%82%B9%E3%83%88%E3%83%AA%E3%83%BC%E3%83%A0%E3%82%92pipe%E3%81%99%E3%82%8B%E3%81%A8%E3%81%8D%E3%81%AB%E3%83%AA%E3%82%BD%E3%83%BC%E3%82%B9%E3%83%AA%E3%83%BC%E3%82%AF%E3%82%92%E8%B5%B7/)。今回のプログラムではバッチ処理で書き込みが失敗したら即終了するため、そのままにしていますが、daemonやElectron等で動き続けるアプリを書く場合は正しく実装すべきです。