Google Cloud StorageへのResumable Upload(再開可能アップロード)の実装手順

2024/12/21に公開

株式会社モニクルのエンジニアのmakominationです!
今年モニクルへ転職いたしました。何卒よろしくお願いいたします!
モニクル Advent Calendar 2024の21日目です!

はじめに

今回は、Google Cloudが提供するストレージサービスCloud Storageについてご紹介します。
Cloud Storageでは、ウェブサービスなどで利用するファイルをバケットに保存することができます。

この記事では、ウェブアプリケーションを通じてユーザーが動画などの大きなファイルをアップロードできるResumable Uploadについてお話しします。

Resumable Upload(再開可能なアップロード)とは?

各ファイルはバケットにオブジェクトという単位で保存されます。
バケットへのオブジェクトのアップロード方式にはいくつか存在しますが、
今回はResumable Upload(再開可能なアップロード)についてご説明します。

通常のアップロードでは途中で中断してしまうと、また最初からやり直しになります。
Resumable Uploadでは中断しても、途中からアップロード再開可能になります。
サイズが大きなファイルをアップロードする際に有効な方法です。

実装手順

1. Signed URL(署名付きURL)を払い出す

Signed URLとは、ユーザーが Google アカウントを持っているかどうかにかかわらず、そのURLを知っていればバケットへのアップロードが可能となります。
セキュリティのため、有効期限を設ける必要があります。(下記のexpires)

Typescriptでの例

import { Storage } from "@google-cloud/storage";

const storage = new Storage();

const options = {
  version: "v4",
  action: "resumable",
  expires: Date.now() + 10 * 60 * 1000, // 10分,
} as const;

const [signedUrl] = await storage
  .bucket(this.bucketName)
  .file(filePath)
  .getSignedUrl(options);

2. Cloud StorageにResumable Sessionを開始するためPOSTリクエストを送る

Resumable Uploadを行うためには、まずResumable Sessionを張る必要があります。
1で取得した signedUrl に向けて、以下のようなPOSTリクエストを送ることで開始できます。

レスポンスのlocationヘッダーに、アップロード用のURLが格納されています。(sessionUri)

Typescriptでの例

// 1の続き
const session = await fetch(signedUrl, {
  method: "POST",
  headers: {
    Origin: origin,
    "Content-Type": contentType,
     "x-goog-resumable": "start",
  },
});
if (!session) {
  throw new Error("Resumable session failed");
}

const sessionUri = session.headers.get("location");

注意として、ここで設定したOriginがこの後のステップに出てくるファイルアップロードのリクエストのOriginとみなされます。もしSessionを開始する環境とファイルアップロードする環境が異なる場合は、ファイルアップロードのリクエストを送る環境のOriginと同一のものをここでは指定してください。 (指定しない場合CORSエラーにひっかかります。)

3. チャンクごとにファイルのアップロード

2で取得した sessionUri に向けて、ファイルをアップロードします。
ファイルはチャンクに分割してアップロードしていきます。

Typescriptでの例(イメージ)
(以下は実装イメージを記したもので、実際に動作するコードではないです 🙇)

const CHUNK_SIZE = 256 * 32 * 1024 // 8MiB
const stream = file.stream();

// ファイルのデータをチャンクごとに読み取る関数
async readFileIterator = (stream, chunkSize) => {
  const reader = stream.getReader();
  let buffer = new Uint8Array(chunkSize);
  for(;;) {
    const {value, done} = await reader.read();
    if (done) {
        // 最後のチャンクをyield buffer;する
        break;
    }
    // valueをbufferにsetしていく。
    // bufferが満たされた時(chunkSizeに達した時)yield buffer;してbufferをリセット。
  }
}

// ファイルをチャンクごとにアップロード
for await (const chunk of readFileIterator(stream, CHUNK_SIZE)) {
  const res = await fetch(sessionUri, {
    method: "PUT",
    body: chunk,
    headers: {
      "Content-Range": range, // Content-Rangeヘッダーを適切に設定することで、サーバーに現在何番目のchunkかを正しく伝えることができます
      "Content-Length": chunk.byteLength
    }
  });
  // Resumable Uploadの正常終了
  if (response.status === 200) {
    break;
  }
 // 続きのチャンクがあるので続行
  if (response.status === 308) {
    continue;
  }
}

もし途中でアップロードが中断された場合は、以下の形式のリクエストでステータスを確認できます。

curl -i -X PUT \
    -H "Content-Length: 0" \
    -H "Content-Range: bytes */OBJECT_SIZE" \
    "SESSION_URI"

アップロード再開可能な場合、上のリクエストのレスポンスステータスは308となっており、かつこれまでの経過を示す Rangeヘッダーが含まれているので、Content-RangeRangeヘッダーの値を入れて途中のchunkからアップロードを開始することでアップロード再開できます。

参考

https://developer.mozilla.org/ja/docs/Web/API/ReadableStreamDefaultReader/read
https://cloud.google.com/storage/docs/performing-resumable-uploads?hl=ja#status-check

おわりに

今回はCloud StorageのBucketへのResumable Uploadについてご紹介いたしました。
実装手順が複雑で、また3のチャンクごとのアップロードの実装の難易度が高いですが、この記事がどなたかの実装のヒントになれれば幸いです!

ここまでお読みいただきありがとうございました!!!

Discussion