webアプリケーションでファイルアップロード機能をよく提供すると思います。

ほんの数ファイルのアップロードであれば、署名付きURLを使うことが多いですが、フォルダのようにファイル数が読めないような場合は署名付きポリシードキュメントを使うと便利です。

署名付きURLの特徴

ここではフォルダアップロードする際に困る点だけ説明します。

その他の特徴については公式ドキュメント1を参照してください。

署名付きURLは主に次のようなフォーマットになっており、パスにオブジェクト名まで含まれています。

https://storage.googleapis.com/example-bucket/cat.jpeg?X-Goog-Signature=...

これはつまり、アップロードできるオブジェクト名が1つに限定されてしまうということです。

複数ファイルをアップロードしたい場合、ファイルごとに発行する必要があり、計算量はO(n)となってしまいます。

署名付きポリシードキュメントの特徴

前述の計算量がO(n)がネックになるケースでは、署名付きポリシードキュメントを使用することで解決できます。

https://cloud.google.com/storage/docs/authentication/signatures?hl=ja#policy-document

ポリシードキュメントには次のような特徴があります。

  • アップロード先のバケットを指定できる
  • ファイル名のプレフィックスを限定できる
    • 特定のフォルダ以下に対しての操作を許可できる
    • それ以外のフォルダにはアップロードできない
  • Content-TypeやContent-Lengthなどの条件を指定できる
  • POSTのみ可能でGETやDELETEは不可

特にフォルダ配下に対してアップロードする権限を付与できるので、フォルダ内のファイルを全てアップロードするようなケースでは、署名の発行の計算量がO(1)となり、大幅に効率化できます。

実装してみる

apiにrails、フロントエンドにreactを使用したアプリケーションで、GCSの署名付きポリシードキュメントを発行する例を示します。

まずapiは、google-cloud-storage gemを使用して、署名付きポリシードキュメントを発行し、返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem "google-cloud-storage"
end

class Storage
  # @param prefix [String] The prefix for the objects to be uploaded
  # @param max_file_size [Integer] The maximum allowed file size for uploads
  # @return [Hash] A hash containing the URL and form fields for the signed policy document
  def self.generate_policy_document_for_prefix(prefix:, max_file_size: 100 * 1024 * 1024)
    post_object = bucket.generate_signed_post_policy_v4(
      "#{prefix}${filename}",
      expires: 3600,
      conditions: [
        [ "starts-with", "$key", prefix ],
        [ "content-length-range", 0, max_file_size ]
      ]
    )

    {
      url: post_object.url,
      fields: post_object.fields
    }
  end

  private

  def self.bucket
    @bucket ||= storage_client.bucket({bucket_name})
  end

  def self.storage_client
    @storage_client ||= begin
      Google::Cloud::Storage.new(
        credentials: load_service_account_key
      )
    end
  end

  def self.load_service_account_key
    keyfile_path = Rails.root.join({service_account.json}).to_s

    JSON.parse(File.read(keyfile_path))
  end
end

次にフロントエンドで、発行されたポリシードキュメントを使用して、フォルダ内のファイルをアップロードします。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
import React, { useState } from 'react';

interface PolicyResponse {
  prefix: string;
  upload_url: string;
  fields: {
    key: string;
    policy: string;
    "x-goog-algorithm": string;
    "x-goog-credential": string;
    "x-goog-date": string;
    "x-goog-signature": string;
  };
}

export const FolderUpload = () => {
  const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null);
  const [uploading, setUploading] = useState(false);
  const [message, setMessage] = useState("");

  const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
    const files = event.target.files;
    setSelectedFiles(files);
    setMessage(""); // エラーメッセージを消す
  };

  const handleUpload = async () => {
    if (!selectedFiles) {
      setMessage("ファイルを選択してください");
      return;
    }

    setUploading(true);
    setMessage("");

    try {
      const files = Array.from(selectedFiles);
      
      // Extract file paths only (no need to send all metadata)
      const filePaths = files.map(
        (file) => (file as any).webkitRelativePath || file.name,
      );

      // 先程作成したRails apiのエンドポイントにリクエストして署名付きポリシードキュメントを取得
      const response = await fetch("/api/policy_uploads/generate_policies", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          user_id: "user123",
        }),
      });

      if (!response.ok) {
        throw new Error("ポリシードキュメントの取得に失敗しました");
      }

      const policy: PolicyResponse = await response.json();

      // 並列アップロード
      const result = await uploadWithConcurrency(files, filePaths, policy);

      if (result.failed === 0) {
        setMessage(`全${result.completed}ファイルのアップロードが完了しました`);
        setSelectedFiles(null);
        const fileInput = document.getElementById(
          "folder-upload",
        ) as HTMLInputElement;
        if (fileInput) fileInput.value = "";
      } else {
        setMessage(
          `${result.completed}ファイル成功、${result.failed}ファイル失敗しました`,
        );
      }
    } catch (error) {
      setMessage("エラーが発生しました: " + (error as Error).message);
    } finally {
      setUploading(false);
    }
  };

  const uploadWithConcurrency = async (
    files: File[],
    filePaths: string[],
    policy: PolicyResponse,
    concurrency = 8, // httpのバージョンやブラウザに合わせて調整する
  ) => {
    let completed = 0;
    let failed = 0;
    const failedFiles: string[] = [];

    for (let i = 0; i < files.length; i += concurrency) {
      const batch = files.slice(i, i + concurrency);
      const batchPaths = filePaths.slice(i, i + concurrency);

      const results = await Promise.allSettled(
        batch.map((file, idx) => {
          
          return uploadFileWithPolicy(
            file,
            batchPaths[idx],
            policy,
          );
        }),
      );

      results.forEach((result, idx) => {
        const file = batch[idx];

        if (result.status === "fulfilled" && result.value) {
          completed++;
        } else {
          failed++;
          failedFiles.push(file.name);
        }
      });
    }

    return { completed, failed, failedFiles };
  };

  const uploadFileWithPolicy = async (
    file: File,
    filePath: string,
    policy: PolicyResponse,
    maxRetries = 3, // 最大リトライ回数。必要に応じて調整してください
  ): Promise<boolean> => {
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        const formData = new FormData();
        formData.append("key", policy.fields.key); // Contains "uploads/user123/${filename}"
        formData.append("Content-Type", file.type || "application/octet-stream");
        formData.append("policy", policy.fields.policy);
        formData.append("x-goog-algorithm", policy.fields["x-goog-algorithm"]);
        formData.append("x-goog-credential", policy.fields["x-goog-credential"]);
        formData.append("x-goog-date", policy.fields["x-goog-date"]);
        formData.append("x-goog-signature", policy.fields["x-goog-signature"]);
        // 最後にファイルを設定すると決められている
        // see: https://cloud.google.com/storage/docs/xml-api/post-object-forms#storage-post-policy-ruby
        formData.append("file", file); 

        const uploaded = await new Promise<boolean>((resolve, reject) => {
          const xhr = new XMLHttpRequest();

          xhr.upload.addEventListener("progress", (e) => {
            if (e.lengthComputable) {
              const percentComplete = (e.loaded / e.total) * 100;
              // 割愛していますが、必要に応じてアップロードの進捗状況を取得できる
            }
          });

          xhr.addEventListener("load", () => {
            if (xhr.status === 200 || xhr.status === 204) {
              resolve(true);
            } else {
              console.error(
                `Upload failed for ${file.name} (attempt ${attempt}/${maxRetries}):`,
                xhr.status,
                xhr.statusText,
                xhr.responseText,
              );

              // 400系のエラーはリトライしない
              if (xhr.status >= 400 && xhr.status < 500) {
                resolve(false);
              } else {
                reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
              }
            }
          });

          xhr.addEventListener("error", () => {
            reject(new Error("Network error"));
          });

          xhr.addEventListener("abort", () => {
            reject(new Error("Upload aborted"));
          });

          xhr.open("POST", policy.upload_url);
          xhr.send(formData);
        });

        if (uploaded) {
          return true;
        }
      } catch (error) {
        console.error(
          `Upload error for ${file.name} (attempt ${attempt}/${maxRetries}):`,
          error,
        );
      }

      // Wait before retrying (exponential backoff)
      if (attempt < maxRetries) {
        await new Promise((resolve) =>
          setTimeout(resolve, Math.pow(2, attempt) * 1000),
        );
      }
    }

    return false;
  };

  return (
    <>
      <h1>フォルダアップロード</h1>

      <input
        type="file"
        id="folder-upload"
        onChange={handleFileSelect}
        multiple
        {...{ webkitdirectory: "true", directory: "true" }}
      </>

      {selectedFiles && (
        <div>選択されたファイル: {selectedFiles.length}</div>
      )}

      <button
        onClick={handleUpload}
        disabled={!selectedFiles || uploading}
        className="btn btn-primary upload-btn"
      >
        {uploading ? "アップロード中..." : "アップロード"}
      </button>

      {message && <div>{message}</div>}
    </>
  )
}

最低限のコードサンプルを示してみました。多分動くはずです…

これで署名付きポリシードキュメントを発行→ファイルをダイレクトアップロードできます。

もちろんバケットは非公開なので、ポリシードキュメントで指定した条件以外ではアップロードできないため、セキュリティ的にも安心です。

また、上記以外の特徴に加えて、XMLHttpRequestを使用しているので、アップロードの進捗状況も取得可能ですし、並列アップロードの仕組みも実装しています。(ポリシードキュメントとは無関係ですが)

並列アップロードのため再開可能なアップロードが使用しづらい(もしくはポリシードキュメントだと使えない可能性もあるかもしれないです)点がネックですが、要件次第では問題ないでしょう。

最後に

フォームの作成方法やformDataに必要なフィールドなどの詳細については公式ドキュメント2を参照してください。