📘

AWS SDKでよく使う記述についてまとめてみた

2024/05/17に公開

はじめに

使用例ではNext.js、TypeScriptの場合によく使用する記述をまとめています。
S3とDynamoDBについて記述していますが、今後他のサービスについても追記する予定です。

AWSの認証情報を安全に管理する

AWSの認証情報、特にアクセスキーの安全を確保するためには、SDKの使用は必ずサーバーサイドで行ってください

もしクライアントサイドでSDKを利用したい場合は、Next.jsのAPIルートを通じて操作を行うようにしてください。クライアントサイドで直接SDKを使用してしまうと、ブラウザのネットワークタブから認証情報が確認されてしまい、情報が漏洩するリスクがあります。

特にRequest Headers > AuthorizationヘッダーにはAWSアクセスキーが含まれています。(黒塗りの部分)

Next.jsのAPIルートを利用する際、アクセスキーが漏洩しない理由は、APIルートがサーバーサイドで実行されるためです。この処理はクライアント(ユーザーのブラウザ)とは独立してサーバー上で完了し、ユーザーのブラウザには処理結果のみが送信されます。

このようにすることで、機密情報の安全を確保しつつ、クライアントからのリクエストに対して必要なデータだけを安全に提供することができます。
https://zenn.dev/nenenemo/articles/59ca1b03fcf234

SDKとは

SDK(Software Development Kit)は、AWSのサービスをプログラムから利用するためのツールのことで、プログラミング言語で記述されます。
https://aws.amazon.com/jp/what-is/sdk/

AWS SDKのインストール

複数のAWSサービスを使用する場合

aws-sdkはAWSの全サービスにアクセスするための包括的なライブラリです。複数のAWSサービスを使用し、プロジェクトがAWSの幅広い機能にアクセスする必要がある場合は、aws-sdkを使用した方が良いと思います。

npm install aws-sdk

サービスに特化したライブラリ

必要なサービスに特化したライブラリを使用することで、不要な機能を含まずにライブラリを軽量化することができます。

npm install @aws-sdk/client-<サービス名>

↓DynamoDBのライブラリ
https://www.npmjs.com/package/@aws-sdk/client-dynamodb

DynamoDBとS3の場合

aws-sdkをインストールしている場合は気にしなくて良いです。

DynamoDBとS3の操作に特化したライブラリとして、@aws-sdk/lib-<サービス名>という形式のパッケージが提供されています。

これらのライブラリを使用すると、@aws-sdk/client-<サービス名>よりもシンプルに記述することができます。
https://zenn.dev/fusic/articles/3a4ff465a85dcd

AWS認証設定

一度しか記述しない場合はファイルを分けなくても良いのですが、それ以外の場合は、AWS認証設定ファイルを作成しておくと便利です。環境変数は.envに設定して読み込んでいます。

src/app/awsCredential.ts
import AWS from 'aws-sdk';

AWS.config.update({
  region: process.env.REGION || '', // DynamoDB のリージョンを指定
  accessKeyId: process.env.ACCESS_KEY_ID || '', // IAM ユーザーアクセスキー
  secretAccessKey: process.env.SECRET_ACCESS_KEY || '', // IAM ユーザーシークレットアクセスキー
});

export default AWS;

インポート文を記述して使用してください。

src/app/api/aws/route.ts
import AWS from '../../awsCredential';

リクエスト

GETリクエスト

page
try {
  const response = await axios.get('/api/aws');
  console.log('response', response);
  console.log('response.data', response.data);
} catch (error) {
  console.error('データの取得に失敗しました。:', error);
}

POSTリクエスト

page
const response = await axios.post('/api/aws', {
        artist: artist, // プロパティと変数名
        songTitle: songTitle, 
      });
      console.log('response', response);
      console.log('response.data', response.data);
    } catch (error) {
      console.error('ネットワークエラー:', error);
    }
  };

プロパティと変数の名前が同じなので、オブジェクトリテラルの省略記法を使用すると下記のように記述できます。
https://zenn.dev/nenenemo/articles/49c1d2a19fc1c7#オブジェクトリテラルの省略記法

page
const response = await axios.post('/api/aws', {
        artist, 
        songTitle,
      });
      console.log('response', response);
      console.log('response.data', response.data);
    } catch (error) {
      console.error('ネットワークエラー:', error);
    }
  };

APIルートの設定

下記は各サービスでのAPIルートの設定です。

S3

取得

getObject

src/app/api/aws/route.ts
export async function GET() {
  // S3クライアントの作成
  const s3 = new AWS.S3(); // AWS S3クライアントをインスタンス化

  const params = {
    Bucket: process.env.S3_BUCKET_NAME || '', // バケット名を環境変数から取得
    Key: process.env.FILE_PASS || '', // 取得したいオブジェクトのキー(ファイルパス)を指定
  };

  try {
    const data = await s3.getObject(params).promise();
      return NextResponse.json(data);
  } catch (error) {
    throw error;
  }
}

画像や動画を取得する

responseTypeblob(Binary Large OBjectの略、バイナリデータを表すオブジェクト)オプションを指定してリクエストを送信することで、レスポンスデータをバイナリ形式(Blobオブジェクト)で受け取ることができます。

URL.createObjectURLメソッドを使用して、受け取ったBlobデータから一時的なURLを生成します。
このURLは、画像や動画などのバイナリデータをブラウザで表示するために使用できます。

ブラウザを閉じるまで 生成されたURL有効でメモリに常駐するため、URL.revokeObjectURLを使用して削除する必要があります。

page.tsx
  const [url, setUrl] = useState('');

 try {
      const response = await axios.get('/api/aws', {
        responseType: 'blob',
      });
      console.log('response', response);
      console.log('response.data', response.data);
      const url = URL.createObjectURL(response.data);
      setUrl(url);
    } catch (error) {
      setError('データの取得に失敗しました。');
      console.error('Error fetching data:', error);
    }
  return (
{/* 画像 */}
 {url && (
          <img
            src={url}
            alt='S3 Image'
            style={{ width: '300px', height: 'auto' }}
          />
        )})
{/* 動画 */}
{url && (
          <video
            src={videourl}
            controls
            style={{ width: '300px', height: 'auto' }}
          />
        )}

data.Bodyをバイナリデータを扱うための型であるUint8Array型として扱うように記述しています。
Content-TypeにバイナリデータのMIMEタイプを設定してください。

src/app/api/aws/route.ts
export async function GET() {
  // S3クライアントの作成
  const s3 = new AWS.S3(); // AWS S3クライアントをインスタンス化

  const params = {
    Bucket: process.env.S3_BUCKET_NAME || '', // バケット名を環境変数から取得
    Key: process.env.FILE_PASS || '', // 取得したいオブジェクトのキー(ファイルパス)を指定
  };

  try {
    const data = await s3.getObject(params).promise();
    if (data.Body) {
      return new NextResponse(data.Body as Uint8Array, {
        headers: {
          'Content-Type': 'application/octet-stream',
        },
      });
    } else {
      return new NextResponse('アイテムが見つかりませんでした', {
        status: 404,
      });
    }
  } catch (error) {
    console.error('S3からファイルを取得する際にエラーが発生しました:', error);
    return new NextResponse(
      'S3からファイルを取得する際にエラーが発生しました',
      {
        status: 500,
      }
    );
  }
}export async function GET() {
  // S3クライアントの作成
  const s3 = new AWS.S3(); // AWS S3クライアントをインスタンス化

  const params = {
    Bucket: process.env.S3_BUCKET_NAME || '', // バケット名を環境変数から取得
    Key: process.env.FILE_PASS || '', // 取得したいオブジェクトのキー(ファイルパス)を指定
  };

  try {
    const data = await s3.getObject(params).promise();
    if (data.Body) {
      return new NextResponse(data.Body as Uint8Array, {
        headers: {
          'Content-Type': data.ContentType || 'application/octet-stream',
        },
      });
    } else {
      return new NextResponse('アイテムが見つかりませんでした', {
        status: 404,
      });
    }
  } catch (error) {
    throw error;
  }
}

画像、動画のアップロード

署名付きURLを使用してアップロードする

  const [file, setFile] = useState<File | null>(null);

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    let file = null;
    if (event.target.files) {
      file = event.target.files[0];
    }
    setFile(file);
  };

  const uploadFile = async () => {
    if (!file) return; // ファイルが選択されていなければ何もしない
    try {
      const response = await axios.post('/api/aws', {
        file,
        fileName: file.name,
        fileType: file.type,
      });
      console.log('アップロードが成功しました: ', response);
    } catch (error) {
      console.error('レスポンスが取得できませんでした', error);
    }
  };

 return (
        <div>
          <input type='file' onChange={handleFileChange} />
          <button onClick={uploadFile}>S3にアップロードする</button>
        </div>
  );
}

const [file, setFile] = useState<File | null>(null);のようにnullを使用しない場合は、Fileオブジェクトのプロパティを空にしたものを設定してください。

page.tsx
const [file, setFile] = useState<File>({
  name: '',
  size: 0,
  type: '',
  lastModified: Date.now(),
});

サーバーサイドの記述

src/app/api/aws/route.ts
export async function POST(req: NextRequest) {
  // リクエストボディからデータを抽出
  const { file, fileName, fileType } = await req.json();

  // S3クライアントの初期化
  const s3 = new AWS.S3();

  const params = {
    Bucket: process.env.S3_BUCKET_NAME || '',
    Key: fileName,
    Expires: 60, // URLの有効期限(秒)
    ContentType: fileType,
  };

  try {
    // アップロード用の署名付きURLを生成
    const signedUrl = await s3.getSignedUrlPromise('putObject', params);
    console.log('生成された署名付きURL:', signedUrl);

    await fetch(signedUrl, {
      method: 'PUT',
      headers: {
        'Content-Type': fileType,
      },
      body: file,
    });

    return NextResponse.json({
      success: true,
      message: 'アップロードが成功しました',
      file: fileName,
    });
  } catch (error) {
    throw error;
  }
}

FormDataを使用する記述

await req.json()を使用すると、ファイルのバイナリデータではなく、そのメタデータのみが取得できます。
ファイルのバイナリデータを扱うためには、FileオブジェクトをFormDataとして送信し、サーバー側で適切に処理する必要があります。

axios.postを使用する場合、リクエストボディとしてFormDataを送信するためには、axios.postの第二引数にformDataを直接渡し、ヘッダーの設定を行う必要があります。methodプロパティは不要です。

  const [file, setFile] = useState<File | null>(null);

  const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    let file = null;
    if (event.target.files) {
      file = event.target.files[0];
    }
    setFile(file);
  };

  const uploadFile = async () => {
    if (!file) return; 
   const formData = new FormData();
    formData.append('file', file);
    formData.append('fileName', file.name);
    formData.append('fileType', file.type);

    formData.forEach((value, key) => {
      console.log('key&value', key, value);
    });

    try {
      const response = await axios.post('/api/aws', formData, {
        headers: {
          'Content-Type': 'multipart/form-data',
        },
      });
      console.log('アップロードが成功しました: ', response);
    } catch (error) {
      console.error('レスポンスが取得できませんでした', error);
    }
  };

 return (
        <div>
          <input type='file' onChange={handleFileChange} />
          <button onClick={uploadFile}>S3にアップロードする</button>
        </div>
  );
}

サーバーサイドの記述

src/app/api/aws/route.ts
export async function POST(req: NextRequest) {
  // formData
  const formData = await req.formData();
  const file = formData.get('file');
  const fileName = formData.get('fileName');
  const fileType = formData.get('fileType');

  if (!file || !fileName || !fileType || !(file instanceof File)) {
    return NextResponse.json(
      { success: false, message: 'ファイルデータが不足しています' },
      { status: 400 }
    );
  }

  // S3クライアントの初期化
  const s3 = new AWS.S3();

  const params = {
    Bucket: process.env.S3_BUCKET_NAME || '',
    Key: fileName,
    Expires: 60, // URLの有効期限(秒)
    ContentType: fileType,
  };

  try {
    // アップロード用の署名付きURLを生成
    const signedUrl = await s3.getSignedUrlPromise('putObject', params);
    console.log('生成された署名付きURL:', signedUrl);

    // fetch
    await fetch(signedUrl, {
      method: 'PUT',
      headers: {
        'Content-Type': fileType.toString(),
      },
      body: file,
    });

    // axiosを使用する場合
    // ファイルデータをArrayBufferとして読み取る
    // fetchを使用する方が低レベルの制御が可能になり、バイナリデータを簡単に送信できる
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);

    // 署名付きURLを使ってS3にファイルをアップロード
    await axios.put(signedUrl, buffer, {
      headers: {
        'Content-Type': file.type,
        'Content-Length': buffer.length,
      },
    });

    return NextResponse.json({
      success: true,
      message: 'アップロードが成功しました',
      file: fileName.toString(),
    });
  } catch (error) {
    throw error;
  }
}

File() コンストラクター

https://zenn.dev/nenenemo/articles/49c1d2a19fc1c7#file()-コンストラクター

署名付きURL

有効期限が設定されており、特定のリソースへの一時的なアクセスを提供するために使用されるURLです。

  • 期限設定: URLには有効期限が設定されており、その期限が過ぎるとURLは無効になります。期限は生成時に設定され、数秒から数日の範囲で設定可能です。

  • セキュリティ: URLは特定のリソースへのアクセスを許可するために、AWSのアクセスキーIDとシークレットアクセスキーを用いて署名されます。この署名により、URLが改竄された場合に無効となります。

  • 直接アクセス: 生成されたURLを通じて、ユーザーまたはシステムは追加の認証なしでリソースにアクセスできます。したがってURLを知っている人は誰でも、指定されたリソースに対してアクセス(読み取りまたは書き込み)が可能になります。

署名付きURLを生成する

getSignedUrlPromiseメソッド

AWS SDK for JavaScript(v2)を使用して、AWS S3の特定のオブジェクトに対して署名付きURLを生成するためのメソッドです。https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html#getSignedUrlPromise-property

getSignedUrlメソッド

AWS SDK for JavaScript (v3) では、getSignedUrlメソッドを使用して署名付きURLを生成します。v3では、@aws-sdk/s3-request-presignerパッケージを使用して、署名付きURLを生成します。
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/example_s3_Scenario_PresignedUrl_section.html

DynamoDB

下記のデータを使用します。
https://zenn.dev/nenenemo/articles/a05c835e6b0725

DynamoDBクライアントの作成

下記を記述してDynamoDBクライアントを作成してください。

const dynamoDB = new AWS.DynamoDB.DocumentClient();

取得

データを取得するために3つの主要な操作が提供されています。
主キーは、プレースホルダーを使用しませんが、主キー以外の属性は属性名を記述する際にプレースホルダーを使用する必要があります。

get

指定された主キーに基づいて、1つのアイテムを取得します。
主に、一意の識別子を持つアイテムを取得するために使用されます。特定のユーザーの情報を取得する場合などです。

GETリクエストの場合

src/app/api/aws/route.ts
export async function GET() {
  // DynamoDBクライアントの作成
  const dynamoDB = new AWS.DynamoDB.DocumentClient();

  const key = {
    artist: 'No One You Know', // パーティションキー
    songTitle: 'My Dog Spot', // 設定している場合はソートキー
  };

  const params = {
    TableName: process.env.TABLE_NAME || '',
    Key: key,
  };

  try {
    const data = await dynamoDB.query(params).promise();
    return NextResponse.json(data);
  } catch (error) {
    throw error;
  }
}

POSTリクエストの場合

Next.jsのサーバーサイドのリクエストとレスポンスを扱うためのモジュールもインポートしてください。

src/app/api/aws/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
  // リクエストボディからデータを抽出
  const { artist, songTitle } = await req.json();

  // DynamoDBクライアントの作成
  const dynamoDB = new AWS.DynamoDB.DocumentClient();

  const key = { artist, songTitle };

  const params = {
    TableName: process.env.TABLE_NAME || '',
    Key: key,
  };

  try {
    const data = await dynamoDB.get(params).promise();
    return NextResponse.json(data);
  } catch (error) {
    throw error;
  }
}

query

指定された主キーに基づいて、複数のアイテムを取得することができます。

src/app/api/aws/route.ts
export async function GET() {
  // DynamoDBクライアントの作成
  const dynamoDB = new AWS.DynamoDB.DocumentClient();

  const expressionAttributeValues = {
    ':artistName': 'The Acme Band', // 検索したいアーティスト名
  };

  const params = {
    TableName: process.env.TABLE_NAME || '',
    KeyConditionExpression: 'artist = :artistName',
    ExpressionAttributeValues: expressionAttributeValues,
  };

  try {
    const data = await dynamoDB.query(params).promise();
    return NextResponse.json(data);
  } catch (error) {
    throw error;
  }
}

scan

テーブル内のすべての項目をスキャンし、すべてのアイテムを取得するために使用されます。

scanでもフィルタリングは可能ですが、テーブル内のすべての項目を読み取り、その後にフィルタリングを適用して条件に一致する項目のみを返します。そのため、フィルタリングする場合はqueryを使用するようにしてください。

src/app/api/aws/route.ts
export async function GET() {
  // DynamoDBクライアントの作成
  const dynamoDB = new AWS.DynamoDB.DocumentClient();

  const params = {
    TableName: process.env.TABLE_NAME || '',
    KeyConditionExpression: 'artist = :artistName',
  };

  try {
    const data = await dynamoDB.scan(params).promise();
    return NextResponse.json(data);
  } catch (error) {
    throw error;
  }
}

更新

src/app/api/aws/route.ts
export async function GET() {
  // DynamoDBクライアントの作成
  const dynamoDB = new AWS.DynamoDB.DocumentClient();

  const key = {
    artist: 'No One You Know', // パーティションキー
    songTitle: 'My Dog Spot', // 設定している場合はソートキー
  };

  // 更新する属性と値
  const Expression = 'set #attribute = :newCategory';
  const expressionAttributeValues = {
    ':newCategory': 'JPOP', // 新しいカテゴリー
  };
  const expressionAttributeNames = {
    '#attribute': 'category', // プレースホルダーに対応する実際の属性名
  };

  const ConditionExpression =
    'attribute_exists(artist) AND attribute_exists(songTitle)';

  // クエリの設定
  const params = {
    TableName: process.env.TABLE_NAME || '',
    Key: key,
    UpdateExpression: Expression,
    ExpressionAttributeValues: expressionAttributeValues,
    ExpressionAttributeNames: expressionAttributeNames,
    ConditionExpression: ConditionExpression,
    ReturnValues: 'ALL_NEW', // オプション 更新した内容をdataとして受け取る際に必要
  };

  try {
    const data = await dynamoDB.update(params).promise();
    return NextResponse.json(data);
  } catch (error) {
    throw error;
  }
}

Condition Expressions

条件式を使用して、特定の条件を満たしている場合にのみ実行されるようにすることができます。
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html

今回の場合だと、artistsongTitleが存在する場合にのみ更新が実行され、存在しない場合はエラーが返されます。

ConditionExpressionがない場合には、DynamoDBは指定されたキーが存在するかどうかを確認せずに更新操作を行うので、キーが存在しない場合には新しいアイテムが作成されてしまいます。

トランザクション

transactWriteメソッド

更新をtransactWriteメソッドを使用してトランザクション管理を行ってみます。
https://docs.aws.amazon.com/sdkfornet/v3/apidocs/items/DynamoDBv2/TTransactWrite.html

paramstryブロック内をtransactWriteメソッドで使用できる記述に変更してください。
transactWriteメソッドでは、ReturnValuesパラメータを使用することはできないようなので、更新後のデータを取得してレスポンスしています。

src/app/api/aws/route.ts
const params = {
    TransactItems: [
      {
        Update: {
          TableName: process.env.TABLE_NAME || '',
          Key: key,
          UpdateExpression: Expression,
          ExpressionAttributeValues: expressionAttributeValues,
          ExpressionAttributeNames: expressionAttributeNames,
        },
      },
      // 意図的にエラーを発生させるために存在しないテーブルを使用
      {
        Update: {
          TableName: 'NonExistentTable',
          Key: {
            Key: 'value',
          },
          UpdateExpression: 'SET #attribute = :newCategory',
          ExpressionAttributeNames: {
            '#attribute': 'category',
          },
          ExpressionAttributeValues: {
            ':newCategory': 'KPOP',
          },
        },
      },
    ],
  };

try {
    // トランザクション開始
    const data = await dynamoDB.transactWrite(params).promise();

    // 更新後のデータを取得
    const getParams = {
      TableName: process.env.TABLE_NAME || '',
      Key: key,
    };

    // transactWrite
    const result = await dynamoDB.get(getParams).promise();
    const updatedItem = result.Item;
      return NextResponse.json(updatedItem);
    } 

トランザクションのライフサイクル

下記の操作はAWSによって内部的に管理されているので、明示的にトランザクションのコミットや終了を行う必要はありません。
https://aws.amazon.com/jp/blogs/database/making-coordinated-changes-to-multiple-items-with-amazon-dynamodb-transactions/

開始 (Begin)

transactWriteの呼び出し時に自動的に行われます。

コミット (Commit)

すべての操作が成功した場合に自動的に行われます。

ロールバック (Rollback)

いずれかの操作が失敗した場合に自動的に行われます。

DynamoDB:主キー(パーティションキーとソートキーの組み合わせ)の値を更新する場合

実際には新しいアイテムを挿入(put)して古いアイテムを削除(delete)する方法が一般的です。

更新しようとするとアイテムを一意に識別するための要素なので更新することができず、下記が表示されます。

ValidationException: One or more parameter values were invalid: Cannot update attribute <パーティションキーまたはソートキー>. This attribute is part of the key

handleSubmit関数

react-hook-formライブラリの一部で、フォーム送信時のカスタム処理を実装するために使用されます。
https://react-hook-form.com/docs/useform/handlesubmit

event.preventDefault()を使用してデフォルトのフォーム送信動作を防ぎます。フォームが送信されてもページがリロードされないようにしています。

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
}

getSignedUrlPromise

署名付きURL

署名付きURLを使う目的は、特定の操作(この場合はファイルアップロード)を AWS SDK の認証情報をクライアントに公開することなく行うためです。AWS S3 の署名付きURLを使用する場合、通常はクライアント(ブラウザなど)が直接署名付きURLを介して S3 にファイルをアップロードします。

https://vercel.com/templates/next.js/aws-s3-image-upload-nextjs
https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-s3-presigned-post/#generate-a-presigned-post

終わりに

何かありましたらお気軽にコメント等いただけると助かります。
ここまでお読みいただきありがとうございます🎉

Discussion