初めまして、弊社のプロダクト「KRONOS DRIVE」開発メンバーの大野です。
2024年に突入しましたが、年末年始はどのように過ごされましたでしょうか? 私はというと、カリフォルニアのディズニーランドで年越しをしてきました。初めての年越しディズニーでとても楽しかったのですが、夜寒すぎたのと疲労でニューイヤーのイベントが終わったらすぐにホテルへ戻りました笑
さて、以前「KRONOS DRIVE」の機能開発において、AWS SDKをフロント側で使用せず、APIで発行した署名付きURLでブラウザからS3へ直接アップロードする機能を実装したため、その方法を紹介させていただきます。
Amazon S3とは?
ご存知の方も多いと思いますが、念のためS3についても簡単に説明します。 Amazon S3の公式ページ*1によると、以下のように説明されています。
Amazon S3 は、業界最高水準のスケーラビリティ、データ可用性、セキュリティ、およびパフォーマンスを提供するオブジェクトストレージサービスです。
またS3とは、「Simple Storage Service」のことを略した呼び方になります。
AWS SDKとは?
こちらもAWSの公式ページ*2によると、SDKについて以下のように定義されています。
Software Development Kit (SDK) は、開発者向けのプラットフォーム固有の構築ツールのセットです。特定のプラットフォーム、オペレーティングシステム、またはプログラミング言語で実行されるコードを作成するには、デバッガー、コンパイラー、ライブラリなどのコンポーネントが必要です。SDK は、ソフトウェアの開発と実行に必要なすべてを 1 か所にまとめます。さらに、ドキュメント、チュートリアル、ガイドなどのリソースや、アプリケーション開発を高速化するための API やフレームワークも含まれています。
AWS SDKを使用したブラウザからのアップロード方法
AWS SDKを使用してファイルをアップロードする仕組みを簡単にまとめた図がこちらになります。
①② 一時的な認証情報取得
AWSへ直接アップロードをするために必要な認証情報をバックエンドから取得します。
③ 一時的な認証情報を使用してS3へアップロード
AWS SDK for JavaScriptを使用してブラウザからアップロードをする場合には、マルチパートアップロード*3にも対応しているManagedUpload
などを使用することができます。
const AWS = require('aws-sdk'); const upload = new AWS.S3.ManagedUpload({ params: { Bucket: 'バケット名', Key: 'アップロードのキー', Body: 'ファイル' } });
④ アップロード結果
アップロード結果が返却されます。
署名付きURL(Presigned URL)とは?
Amazon S3は基本的には権限を持ったアカウント以外からの操作はできませんが、署名付きURLを発行して使用することで、指定した行動を指定した範囲内でなら可能になります。 AWS Security Token Service を使用しての発行なら最大36時間、IAMユーザーなら最大7日間の有効期間を持ったURLを発行できます。
詳しくはAWSのユーザーガイドをご覧いただければと思います。
署名付きURLを使用したブラウザからのアップロード方法
通常アップロードの場合
サイズが大きくないファイルであれば、単純に以下の処理を1回するだけで1ファイルのアップロードが可能です。
署名付きURLはバックエンドで発行します。
マルチパートアップロードの場合
AWS SDK、REST API、 AWS CLI を使用していてファイルサイズが5GB以下であれば通常アップロードが可能です。しかしそれより大きなサイズの場合は、オブジェクトをいくつかのパートに分けてアップロードすることが必要になります。 マルチパートアップロードであれば、最大5TB のサイズの単一の大容量オブジェクトをアップロードできます。
また下図③〜⑥の処理は、パートの個数分繰り返されます。
AWS SDKと署名付きURL使用時のメリット・デメリットの比較
2種類のブラウザからのアップロード方法を確認したところで、それぞれのメリットとデメリットを比較してみましょう。
メリット・デメリットの比較表
方法 | メリット | デメリット |
---|---|---|
ブラウザ上でAWS SDKを使用 |
|
|
署名付きURLを使用 |
|
|
メリット&デメリットを比較した結果、元々署名付きURL発行やマルチパートアップロード用のAPIがバックエンドに存在していて追加開発の範囲が少ないことと、今後のプロダクトの方向性を考慮した結果、今回は署名付きURLを使用した直接アップロードを採用することにしました。
実装の内容
それでは、署名付きURLを使ったブラウザからの直接アップロードの実装内容を紹介していきます。 なおAWS SDKはv2を、CORS設定は完了していることを前提に実装しています。
構成
バックエンド側ではAWS SDKを使用したAPIを用意し、フロントエンド側では、それぞれのAPIを叩けるような処理を用意します。 APIは「アップロード開始」「署名付きURL発行」「アップロード完了」の3つが必要になります。
バックエンド側(API)の実装
バックエンドのAPIに関しては、AWS SDKのS3クラスを使用して実装をします。
アップロード開始
マルチパートアップロードに必要なアップロードIDの発行をおこなうAPIになります。
S3クラスのcreateMultipartUpload
を使用します。返却される内容はKey
、UploadId
になります。
サンプルコード(クリックで展開)
beginMultipartUpload(name, path) { const options = { region: 'リージョン', signatureVersion: '署名のバージョン', credentials: '認証情報' }; const s3 = AWS.S3(options); // 引数のname、pathからS3のキーを導く const params = { Bucket: 'アップロード先バケット名', Key: 'アップロードオブジェクトのキー' }; return s3.createMultipartUpload(params).promise(); }
署名付きURL発行
パートアップロード用署名付きURLの発行をおこなうAPIになります。
S3クラスのgetSignedUrlPromise
を使用します。返却される内容は、署名付きURL(文字列)
になります。
サンプルコード(クリックで展開)
createMultipartUploadUrl() { const options = { region: 'リージョン', signatureVersion: '署名のバージョン', credentials: '認証情報' }; const s3 = AWS.S3(options); const operation = 'uploadPart'; const params = { Bucket: 'アップロード先バケット名', Key: 'アップロードオブジェクトのキー', UploadId: 'createMultipartUploadで返却されたアップロードID', PartNumber: 'パートNo.' }; return s3.getSignedUrlPromise(operation, params); }
アップロード完了
パートの結合を実行し、S3オブジェクトの情報を返却します。この処理が完了すると、ファイルが確認できるようになります。
S3クラスのcompleteMultipartUpload
を使用します。返却される内容はKey
、ETag
*4、VersionId
になります。
サンプルコード(クリックで展開)
completeMultipartUpload() { const options = { region: 'リージョン', signatureVersion: '署名のバージョン', credentials: '認証情報' }; const s3 = AWS.S3(options); const params = { Bucket: 'アップロード先バケット名', Key: 'アップロードオブジェクトのキー', UploadId: 'createMultipartUploadで返却されたアップロードID', MultipartUpload: { Parts: [ /* 全パートのパートNo.とEタグの配列 */ ] } }; return s3.completeMultipartUpload(params).promise(); }
フロントエンド側(HTML)の実装
今回は、ファイルを選択できる<input>
とアップロードをする<button>
の2つだけ用意します。
サンプルコード(クリックで展開)
<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>アップロードサンプル</title> </head> <body> <input id="load_file" type="file"> <button id="upload_button" onclick="upload()">アップロード</button> <script type="text/javascript" src="{JavaScriptファイル}"></script> </body> </html>
フロントエンド側(JavaScript)の実装
各APIにリクエストする処理と一連の処理を実行する関数を用意します。
共通
ひとまず分割のサイズを100MiBと定めます。 パートのサイズは5MiBから5GiBとなっています。(最後のアップロードパートには最小サイズの制限はありません。)
const CHUNK_SIZE = 100 * 1024 * 1024; // 100MiB
マルチパートアップロードの開始
この処理では、マルチパートアップロード時、アップロード完了時に必要なupload_id
がAPIより返却されます。
サンプルコード(クリックで展開)
// マルチパートアップロードの開始リクエスト async function beginMultipartUpload(objectName, objectPath) { const path = 'マルチパートアップロード開始APIパス'; const headers = { 'Content-Type': 'application/json' }; const body = JSON.stringify({ name: objectName, path: objectPath }); const res = await fetch(path, { method: 'POST', headers, body }); const multipartUpload = await res.json(); return multipartUpload; }
署名付きURL発行&パートアップロード
この処理では、署名付きURL発行のAPIリクエスト、ファイルのパート読み込み、その署名付きURLへパートのアップロードをおこないます。パートのアップロードが完了するとETag
が返却されます。
サンプルコード(クリックで展開)
// presigned URLの発行リクエスト async function createPresignedUploadUrl(params) { const path = 'マルチパートアップロード用署名付きURL発行APIパス'; const headers = { 'Content-Type': 'application/json' }; const res = await fetch(path, { method: 'POST', headers, body: JSON.stringify(params) }); const data = await res.json(); return data.data.presigned_url; } // パートデータのアップロード async function putPartData(presignedUrl, partData, partNumber) { const headers = { 'Content-Type': 'application/octet-stream' }; const res = await fetch(presignedUrl, { method: 'PUT', headers, body: partData }); const etag = res.headers.get('ETag'); return { ETag: etag.replaceAll('"', ''), PartNumber: partNumber }; } // パートデータの読み出し async function readPartData(fileBlob, offset) { const partData = await new Promise((resolve)=>{ const reader = new FileReader(); reader.onload = function(e) { const data = new Uint8Array(e.target.result); resolve(data); }; const slice = fileBlob.slice(offset, offset + CHUNK_SIZE, fileBlob.type); reader.readAsArrayBuffer(slice); }); return partData; } // 署名付きURLの発行、ファイルの分割読み込み、アップロード async function createPresignedUrlAndPutPartData(uploadId, objectKey, fileBlob, index) { const partData = await readPartData(fileBlob, index * CHUNK_SIZE); const partNumber = index + 1; const presignedUrl = await createPresignedUploadUrl({ upload_id: uploadId, object_key: objectKey, part_number: partNumber }); const part = await putPartData(presignedUrl, partData, partNumber); return Promise.resolve(part); }
マルチパートアップロード完了
この処理では、S3にアップロードしたパートをまとめて1つのファイルに戻すために必要な情報であるPartNumber
とETag
がセットになったパートのリストを渡します。
サンプルコード(クリックで展開)
// マルチパートアップロードの完了リクエスト async function completeMultipartUpload(multipartUploadId, multipartMap, objectKey) { const path = 'アップロード完了APIパス'; const headers = { 'Content-Type': 'application/json' }; const body = JSON.stringify({ upload_id: multipartUploadId, parts: multipartMap, key: objectKey }); const res = await fetch(path, { method: 'POST', headers, body }); const data = await res.json(); return data; }
アップロード(アップロードボタンクリックで実行)
実際の実装では、パート数やパートサイズを調整したり、履歴を登録したりと他の処理も実行していますが、今回はあくまで署名付きURLを使用したアップロードについて焦点を当てているため省略します。
ポイントとしては、以下の2点になります。
① 非同期処理の同時実行数を制限
同時実行数について指定をせずに大容量のファイルをアップロードすると、リクエストだけ増えてしまいエラーとなったりブラウザが落ちたりする可能性があるため、今回は最大同時実行数を1ファイルにつき5つとなるよう処理に組み込んでいます。
② パートアップロード結果リストのソート
非同期で実行したマルチパートアップロード処理の結果リストをソートし、パートNo.で昇順になるようにしています。
サンプルコード(クリックで展開)
const uploadData = アップロードファイルのデータ; async function upload() { try { const multipartUpload = await beginMultipartUpload(uploadData.name, uploadData.webkit_relative_path); const uploadId = multipartUpload.data.upload_id; const objectKey = multipartUpload.data.key; // パーツ数 const count = Math.ceil(uploadData.file_size / CHUNK_SIZE); // 最大同時実行数 const CONCURRENCY = 5; const promises = []; const multipartMap = []; let index = 0; // 同時実行数を指定し、無限に同時リクエストされないようにする for (let i = 0; i < CONCURRENCY ; i++) { promises.push(new Promise(async (resolve) => { while (index < count) { const multipartInfo = await createPresignedUrlAndPutPartData(uploadId, objectKey, uploadData.data, index++); multipartMap.push(multipartInfo); } resolve(); })); }; await Promise.all(promises); // 完了時に渡すパートNoとEタグのリストは、パートNoで昇順である必要があるためここでソート multipartMap.sort((a, b) => a.PartNumber - b.PartNumber); const uploadInfo = { upload_id: uploadId, multipart_map: multipartMap, object_key: objectKey } await completeMultipartUpload(uploadInfo.upload_id, uploadInfo.multipart_map, uploadInfo.object_key); } catch (error) { console.log('アップロードに失敗しました: ', error.message); } }
まとめ
今回は、ブラウザからアップロードする方法について比較し、実装内容の紹介をしてみました。 どちらの方法を使用してもそれぞれのメリット・デメリットが存在するかと思いますので、より条件に合った方法を検討する際の材料になれば幸いです。
参考
*2:参照ページリンク:SDK とは何ですか? - SDK の説明 - AWS
*3:アップロードをするファイルが大きい場合、1つのリクエストで送信すると時間がかかってしまいます。 しかしマルチパートアップロードをすることで、ファイルを小さいパートに分割してアップロードし、全てのアップロードが完了した後に結合させることができ、1つのリクエストでするよりも早くアップロードができます。
*4:S3オブジェクトのエンティティタグでファイル内容のmd5ハッシュ値。ただしマルチパートアップロードの場合は、ファイル内容のmd5ハッシュ値とは異なるETagとなります。