🛒

【AWS×Java】SAMで冷蔵庫管理アプリを構築 #2 DynamoDB編(CRUD API①)

に公開

はじめに

当記事の最終ゴールについては以下の記事をご確認ください。
https://zenn.dev/superrelax102/articles/424ebd380d6c53

今回のゴール:API Gateway + Lambda(Java)から DynamoDBへ GET できる
前提読者:AWS初心者、開発初心者

また、今回は以下「【AWS×Java】SAMで冷蔵庫管理アプリを構築 #1 API Gateway + Lambda編(Hello API)」を実施済みの前提で進めます。
https://zenn.dev/superrelax102/articles/b3c7e13459d4ed

以下を実施することで従量課金が発生します。その点については自己責任でお願いします。無料枠があるが上限があります。削除することで課金を止めることが可能です。
また、本記事は学習ログです。詳細は公式ドキュメントを適宜参照してください。

アーキテクチャ概要

今回は以下赤枠部分を完成させます。

図1: サーバレス構成の全体像

「Infrastructure Composer/ Application Composer」でtemplate.yamlに追加

では早速開発を進めます。
backendディレクトリの配下に「template.yaml」があるので右クリックをして
「Open with Infrastructure Composer」を選択してください。
すると以下のような図が表示されているかと思います。
(旧称がInfrastructure Composerらしく、私は「Infrastructure Composer」と表示されていましたが、「Application Composer」と表示されている方はそちらを選択してください)

-図2 Application Composer(前回状態)

まず、リソース欄から「Lambda関数」「DynamoDBテーブル」をドラッグアンドドロップしてください。

-図3 Application Composer(Lambda関数をドラッグアンドドロップ)

-図4 Application Composer(DynamoDBテーブルをドラッグアンドドロップ)

その後、DynamoDBテーブルとLambda関数を接続してください。

-図5 Application Composer(DynamoDBテーブルとLambdaの接続)

また、Lambda関数とDynamoDBテーブルのリソースプロパティは以下のように設定しました。
(オブジェクトをダブルクリックすると表示されます)

①Lambda関数

(以下以外はすべてデフォルト)

項目名 設定値
論理ID FridgeFunction
ソースパス FridgeFunction
ハンドラー fridge.App::handleRequest
環境変数 キー:FRIDGETABLE_TABLE_NAME、値:!Ref FridgeTable

(環境変数はDynamoDBと接続する際に自動で追加されるはずです)

※ハンドラーにはJavaクラスの完全修飾名+メソッドを設定します。
 以下の手順でJavaソースを作成してください。

  1. backend/HelloWorldFunction をコピーして backend/FridgeFunction にリネーム
  2. src/main/java/helloworld ディレクトリを src/main/java/fridge に変更
  3. App.java の package 文を package fridge; に書き換え

また、上記コピーを行うことに伴い、「pom.xml」「AppTest.java」も修正してください。

pom.xml修正内容
  1. 「HelloWorld」の記載が残っているはずなので「Fridge」に変更
  2. 後ほどDynamoDB SDKとテストで「mockito」というものを使うので、必要な定義を追加
pom.xmlの修正内容
 <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
-    <groupId>helloworld</groupId>
+    <groupId>fridge</groupId>
-    <artifactId>HelloWorld</artifactId>
+    <artifactId>Fridge</artifactId>
    <version>1.0</version>
    <packaging>jar</packaging>
    <name>FridgeFunction for SAM CLI.</name>
    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
    </properties>

+    <dependencyManagement>
+      <dependencies>
+        <dependency>
+          <groupId>software.amazon.awssdk</groupId>
+          <artifactId>bom</artifactId>
+          <version>2.25.0</version>
+          <type>pom</type>
+          <scope>import</scope>
+        </dependency>
+      </dependencies>
+    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-lambda-java-core</artifactId>
            <version>1.2.2</version>
        </dependency>
        <dependency>
          <groupId>com.amazonaws</groupId>
          <artifactId>aws-lambda-java-events</artifactId>
          <version>3.11.0</version>
        </dependency>
        <dependency>
          <groupId>junit</groupId>
          <artifactId>junit</artifactId>
          <version>4.13.2</version>
          <scope>test</scope>
        </dependency>
+        <dependency>
+          <groupId>org.mockito</groupId>
+          <artifactId>mockito-core</artifactId>
+          <version>5.12.0</version>
+          <scope>test</scope>
+        </dependency>
+        <dependency>
+          <groupId>software.amazon.awssdk</groupId>
+          <artifactId>dynamodb</artifactId>
+        </dependency>
    </dependencies>

AppTest.java修正内容

以下をそのまま貼り付けてください。
(詳細な説明は割愛します)

AppTest.java
package fridge;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.LambdaLogger;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
import org.junit.Test;
import org.mockito.Mockito;

import static org.junit.Assert.*;

public class AppTest {

    @Test
    public void successfulResponse() {
        // Arrange
        App app = new App();
        Context ctx = Mockito.mock(Context.class);
        LambdaLogger logger = Mockito.mock(LambdaLogger.class);
        Mockito.when(ctx.getLogger()).thenReturn(logger);

        APIGatewayProxyRequestEvent event = new APIGatewayProxyRequestEvent()
                .withHttpMethod("GET")
                .withPath("/items");  // ハンドラが想定しているパスに合わせる

        // Act
        APIGatewayProxyResponseEvent res = app.handleRequest(event, ctx);

        // Assert
        assertNotNull("レスポンスがnullです", res);

        int statusCode = res.getStatusCode();
        assertTrue("想定外のステータスコード: " + statusCode, statusCode == 200 || statusCode == 500);

        assertNotNull("レスポンスボディがnullです", res.getBody());
    }
}

②DynamoDBテーブル

(以下以外はすべてデフォルト)

項目名 設定値
論理ID FridgeTable

次に、前回作成したAPI Gatewayと今回作成したFridgeFunctionを接続します。
前回「GET /hello」というポートを作成してますが、そこに「GET /items」という
ポートを追加します。

③API Gateway

(以下を追加するだけで、それ以外はそのまま)

項目名 設定値
メソッド GET
パス /items

その後、API Gatewayの「GET /items」とLambda関数を接続してください。

-図6 Application Composer(API GatewayとLambdaの接続)

これでいったんtemplate.yamlの修正は完了です。

App.javaにDynamoDBへのCRUD機能を追加

ここからはApp.javaを修正します。helloworldをそのままコピーしているのでそれをベースに書き加えていきます。

①必要なクラスのインポート

App.java
// 以下を追加でインポート
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.ScanRequest;
import software.amazon.awssdk.services.dynamodb.model.ScanResponse;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import java.util.List;
import java.util.stream.Collectors;

②DynamoDBクライアントを生成

Lambda起動時に一度だけDynamoDBクライアントを生成しておくフィールドを定義します。
(クラス配下に定義してください。メソッド配下に定義するとその都度生成されますが、フィールドとして定義することで一度作成されたクライアントを使いまわせます。)

また、DynamoDbClient.create()は環境変数などからAWS資格情報を自動取得します。

App.java
// 以下を追加
private final DynamoDbClient ddb = DynamoDbClient.create();

③handleRequestの中身を「DynamoDBから全件Scan」に置き換える

前回のhelloworldの時には割愛しましたが、当処理の概要について簡単に説明します。

まず、以下の部分ですがRequestHandlerというインターフェースを実装しております。

public class App implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

これによりAppクラス内で「handleRequest」というメソッドの定義が義務付けられます。
さらにジェネリクス「<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent>」
の部分ですが、ここにはhandleRequestのインプット情報とアウトプット情報の型パラメータとして指定しています。
今回のインプットはAPIGatewayからのリクエスト、アウトプットはAPIGatewayへのレスポンスになるためこのように指定しています。例えばLambdaをs3イベントから呼び出す場合は以下のように定義することもできます。

s3イベントから呼び出す場合
implements RequestHandler<S3Event, String>

次に、以下の部分ですがhandleRequestメソッドの呼び出し部分です。

public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) {

inputにはAPIGatewayのリクエスト情報が格納されております。contextにはLambdaが実行時に渡してくる「環境情報の塊」が入っています。例えば、Lambda関数には1リクエストに対して15分という制限時間がありますが、contextにタイムリミットまで残り何分かといった情報等が入っております。

概要の説明はここまでにして、handleRequestメソッドの修正内容を以下に記載します。

App.java
    public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) {
        Map<String, String> headers = new HashMap<>();
        headers.put("Content-Type", "application/json");
        headers.put("X-Custom-Header", "application/json");

        APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent()
                .withHeaders(headers);
+        LambdaLogger logger = (context != null && context.getLogger() != null)
+                ? context.getLogger()
+                :new LambdaLogger() {
+                    @Override
+                    public void log(String message) {
+                        System.out.println(message);
+                    }
+                    @Override 
+                    public void log(byte[] message) {
+                        System.out.println(new String(message, StandardCharsets.UTF_8));
+                    }
+                };

        try {
-           final String pageContents = this.getPageContents("https://checkip.amazonaws.com");
-            String output = String.format("{ \"message\": \"hello world\", \"location\": \"%s\" }", pageContents);
+            String path = (input != null) ? input.getPath() : null;
+            String method = (input != null) ? input.getHttpMethod() : null;
+            // 環境変数からテーブル名取得
+            String tableName = System.getenv("FRIDGETABLE_TABLE_NAME");
+            if (tableName == null) {
+                throw new RuntimeException("FRIDGETABLE_TABLE_NAME env var is not set");
+            }
+
+            // DynamoDBから全件取得(Scan)
+            ScanResponse scanResponse = ddb.scan(ScanRequest.builder()
+                    .tableName(tableName)
+                    .limit(50) // 任意:件数制限
+                    .build());
+
+            // itemのidとnameだけJSONに整形(最小例)
+            List<Map<String, AttributeValue>> items = scanResponse.items();
+            String itemsJson = items.stream()
+                    .map(item -> {
+                        String id = item.getOrDefault("id", AttributeValue.builder().s("").build()).s();
+                        String name = item.getOrDefault("name", AttributeValue.builder().s("").build()).s();
+                        return String.format("{\"id\":\"%s\",\"name\":\"%s\"}", id, name);
+                    })
+                    .collect(Collectors.joining(","));
+
+            String output = "{ \"items\": [" + itemsJson + "] }";
            return response
                    .withStatusCode(200)
                    .withBody(output);
        } catch (IOException e) {
-            return response
-                    .withBody("{}")
-                    .withStatusCode(500);
+            context.getLogger().log("Error: " + e.getMessage());
+            return response.withStatusCode(500).withBody("{\"error\": \"" + e.getMessage() + "\"}");
        }
    }

※補足:LambdaLoggerを匿名クラスにしている理由
LambdaLoggerは「log(String)」と「log(byte[])」の2つのメソッドを持つため、Javaのラムダ式(1メソッド限定)では書けません。そのため、ここではその場限りの匿名クラスを作って2つとも実装しています。

該当箇所抜粋
        LambdaLogger logger = (context != null && context.getLogger() != null)
                ? context.getLogger()
                :new LambdaLogger() {
                    @Override
                    public void log(String message) {
                        System.out.println(message);
                    }
                    @Override 
                    public void log(byte[] message) {
                        System.out.println(new String(message, StandardCharsets.UTF_8));

④不要な部分を削除

以下部分は不要になったので削除してください。

App.java
-import java.net.URL;

-    private String getPageContents(String address) throws IOException{
-        URL url = new URL(address);
-        try(BufferedReader br = new BufferedReader(new InputStreamReader(url.openStream()))) {
-            return br.lines().collect(Collectors.joining(System.lineSeparator()));
-        }
-    }

⑤ビルド&デプロイ

backendフォルダ配下でビルド&デプロイしてください。

ターミナル
cd backend
sam build
sam deploy

動作確認

デプロイが成功していれば、前回同様、以下を実行して動作確認してください。
(末尾は「/items」にしてください。また、Windowsユーザーは「curl.exe」を使うと見やすいです)

ターミナル
curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/items

↓Windowsユーザー
curl.exe https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/items

以下のような結果が返ってきたら成功です。
(まだ何もアイテムを追加していないので返却値は空になっています。)

ターミナル
StatusCode        : 200
StatusDescription : OK
Content           : { "items": [] }

今後の予定

次回はDynamoDBへのアイテム追加やアイテム削除機能を追加します!

※9/23追記 以下、続編をご確認ください!
https://zenn.dev/superrelax102/articles/386db0ea9fcff2

Discussion