Spring Cloud FunctionでAmazon API Gatewayのプロキシ統合なLambda関数をサクッと書いてみる＋DynamoDB Streamのイベントも扱ってみる

# はじめに
Lambda関数をJavaで実装するのは面倒くさい。
Spring Cloud Functionというのを使うと、その辺の煩雑さを良い感じにフレームワークが吸収してくれるらしい。
ということで、本当に良い感じに吸収されてサクッと書けるのかを試してみた。

ちなみに、Spring Cloud Functionはプロキシ統合していないサンプルは多くあれど、統合版はなかなか見つからなかったので、その検討の一助にもなれば。

# 構成
以下のようなMavenプロジェクトを作成する。

```tree
SpringCloudFunctionTest
├── pom.xml
├── serverless.yml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── springcloudfunctiontest
    │   │           ├── AWSLambdaHandler.java
    │   │           ├── Hello.java
    │   │           └── SpringCloudFunctionExampleApplication.java
    │   └── resources
    │       └── application.properties
    └── test
        └── java
            └── com
                └── springcloudfunctiontest
                    └── SpringCloudFunctionExampleApplicationTests.java

```

pom.xmlは↓こんな感じにしておく。

```XML:pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<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/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.6.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.techprimers.serverless</groupId>
    <artifactId>spring-cloud-function-test</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-cloud-function-test</name>
    <description>Demo project for Spring Boot with Spring Cloud Function</description>

    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>Greenwich.SR2</spring-cloud.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-function-adapter-aws</artifactId>
        </dependency>
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-lambda-java-events</artifactId>
            <version>2.0.2</version>
        </dependency>
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-lambda-java-core</artifactId>
            <version>1.1.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Greenwich.SR2</version>
                <type>pom</type>
                <scope>import</scope>
                </dependency>
            </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <dependencies>
                    <dependency>
                        <groupId>org.springframework.boot.experimental</groupId>
                        <artifactId>spring-boot-thin-layout</artifactId>
                        <version>1.0.10.RELEASE</version>
                    </dependency>
                </dependencies>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <configuration>
                    <createDependencyReducedPom>false</createDependencyReducedPom>
                    <shadedArtifactAttached>true</shadedArtifactAttached>
                    <shadedClassifierName>aws</shadedClassifierName>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>
```


# ハンドラのコード
通常のSpring Bootなアプリ同様、```SpringApplication.run```するクラスを作る。

```Java:SpringCloudFunctionExampleApplication.java
package com.springcloudfunctiontest;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringCloudFunctionExampleApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringCloudFunctionExampleApplication.class, args);
    }

}
```

で、Lambdaのイベントハンドラを作る。
正直、このクラスが空っぽで良い理由がよく分からない……。
API Gatewayのバックエンドに置くので、InputとOutputにはそれぞれ```APIGatewayProxyRequestEvent```と```APIGatewayProxyResponseEvent```を設定する。

```Java:AWSLambdaHandler.java
package com.springcloudfunctiontest;

import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
import org.springframework.cloud.function.adapter.aws.SpringBootRequestHandler;

public class AWSLambdaHandler extends SpringBootRequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
}
```

最後に、ビジネスロジックのクラスを作成する。
``APIGatewayProxyRequestEvent```と```APIGatewayProxyResponseEvent```の仕様は[javadoc](https://javadoc.io/static/com.amazonaws/aws-lambda-java-events/2.2.7/com/amazonaws/services/lambda/runtime/events/APIGatewayProxyRequestEvent.html)を確認しよう。

しかし、Spring　Cloud Functionはマルチクラウドでの互換性がウリなのに、こんなインタフェースにしたら完全にAWS特化な実装になっちゃうよなぁ……。

最後のsetBodyのJSONの扱いが雑なのは気にしないように。

```Java:Hello.java
package com.springcloudfunctiontest;

import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;
import org.springframework.stereotype.Component;

import java.util.function.Function;
import java.util.Map;

@Component
public class Hello implements Function<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
    @Override
    public APIGatewayProxyResponseEvent apply(APIGatewayProxyRequestEvent input) {
        Map<String, String> queryStringParameter = input.getQueryStringParameters();

        String id = queryStringParameter.get("id");
        String name = null;

        if( id.equals("11111") ) {
          name = "\"Taro\"";
        } else {
          name = "\"Nanashi\"";
        }

        APIGatewayProxyResponseEvent responseEvent = new APIGatewayProxyResponseEvent();
        responseEvent.setStatusCode(200);
        responseEvent.setBody("{\"name\":" + name + "}");
        return responseEvent;
    }
}
```

# テストコード
テストコードも普通に書ける。

```Java:SpringCloudFunctionExampleApplicationTests.java
package com.springcloudfunctiontest;

import java.util.HashMap;
import java.util.Map;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import static org.assertj.core.api.Assertions.assertThat;

import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent;
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent;

@RunWith(SpringRunner.class)
@SpringBootTest
public class SpringCloudFunctionExampleApplicationTests {

    @Test
    public void HelloTest1() {
        Hello hello = new Hello();
        APIGatewayProxyRequestEvent request = new APIGatewayProxyRequestEvent();

        Map<String,String> queryStringParameter = new HashMap<>();
        queryStringParameter.put("id", "11111");
        request.setQueryStringParameters(queryStringParameter);

        APIGatewayProxyResponseEvent response = hello.apply(request);

        assertThat(response.getBody()).isEqualTo("{\"name\":\"Taro\"}");
    }

    @Test
    public void HelloTest2() {
        Hello hello = new Hello();
        APIGatewayProxyRequestEvent request = new APIGatewayProxyRequestEvent();

        Map<String,String> queryStringParameter = new HashMap<>();
        queryStringParameter.put("id", "22222");
        request.setQueryStringParameters(queryStringParameter);

        APIGatewayProxyResponseEvent response = hello.apply(request);

        assertThat(response.getBody()).isEqualTo("{\"name\":\"Nanashi\"}");
    }
}
```

# デプロイ
手でいちいちS3にアップロードしたり、パイプラインのビルド待ちしたりするのも面倒臭いので、正式版のアプリでないからServerless Frameworkでテキトーにデプロイしよう。

```YAML:serverless.yml
service: test

provider:
  name: aws
  runtime: java8
  timeout: 30

  region: ap-northeast-1

package:
  artifact: target/spring-cloud-function-test-0.0.1-SNAPSHOT-aws.jar

functions:
  hello:
    handler: org.springframework.cloud.function.adapter.aws.SpringBootStreamHandler
    events:
      - http:
          path: testapi
          method: get
          integration: lambda-proxy
          request:
            template:
              application/json: '$input.json("$")'
```

これでデプロイすると、こんな感じでちゃんとRESTAPIが返されるぞ！

```
$ curl -X GET https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/testapi?id=11111; echo
{"name":"Taro"}
```

# 結論
うーん、Springフレームワークの恩恵を全然預かっていないので、普通のLambdaのイベントハンドラを作った時と手間としては変わらなかった気分。もっとフレームワークの機能をふんだんに使うようになったらありがたみがわかるのだろうか。

今回は、JavaでLambda関数を書くならやっぱりAPIGatewayProxyRequestEventを使うと実装が楽だね、ということと、Serverless Frameworkのデプロイがお手軽だね、と言うことは理解できた。

# 2022/3/17追記 DynamoDB StreamsでトリガされるLambdaの場合
DynamoDB StreamsについてもSpring Cloud Functionで呼び出せることを確認したのでメモ。

## メイン処理
メイン処理はAPI Gatewayから変える必要がない。

## ハンドラ
ハンドラは以下のように、DynanmoDBのイベントを受け取れるようにする。

```Java:DynamoDbEventHandler.java
package com.springcloudfunctiontest;

import org.springframework.cloud.function.adapter.aws.SpringBootRequestHandler;

import com.amazonaws.services.lambda.runtime.events.DynamodbEvent;

public class DynamoDbEventHandler extends SpringBootRequestHandler<DynamodbEvent, String> {
}
```

## 関数の実装
関数側も同様に、DynamoDBEventを受け付けられるようにしておく。これで、DynamoDB Streamsのイベントが```apply```で通知され、```input```でJSONの情報を取得できる。

```Java:Hello.java
package com.springcloudfunctiontest;

import java.util.function.Function;
import org.springframework.context.annotation.Configuration;

import com.amazonaws.services.lambda.runtime.events.DynamodbEvent;

@Configuration
public class Hello implements Function<DynamodbEvent, String> {
    @Override
    public String apply(DynamodbEvent input) {
        System.out.println(input);

        return "OK.";
    }
}
```

## Serverless Frameworkの設定
Serverless FrameworkでDynamoDBのイベントを作成することも可能。
気を付けなければいけないのは、ハンドラのクラスがAPI Gatewayと異なること。
今回はちゃんとハンドルしてほしいクラス(```DynamoDbEventHandler```)のクラスパスを指定しよう。

```YAML:serverless.yml
# Welcome to Serverless!
#
# This file is the main config file for your service.
# It's very minimal at this point and uses default values.
# You can always add more config options for more control.
# We've included some commented out config examples here.
# Just uncomment any of them to get that config option.
#
# For full config options, check the docs:
#    docs.serverless.com
#
# Happy Coding!
# https://serverless.com/framework/docs/providers/aws/guide/serverless.yml/

service: test

provider:
  name: aws
  runtime: java8
  timeout: 30
  region: ap-northeast-1

package:
  artifact: build/libs/r-kubota_SpringCloudFunctionTest-aws.jar

functions:
  hello:
    handler: com.springcloudfunctiontest.DynamoDbEventHandler
    events:
      - stream:
          type: dynamodb
          arn:
            Fn::GetAtt:
              - MyDynamoDbTable
              - StreamArn

resources:
  Resources:
    MyDynamoDbTable:
      Type: "AWS::DynamoDB::Table"
      Properties:
        TableName: my-table
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: N
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1
        StreamSpecification:
          StreamViewType: NEW_AND_OLD_IMAGES
```

# 参考資料
DynamoDB Streams版を書くにあたり、「やってみた」レベルの理解度だと全然分からなくて、以下のサイトがかなり参考になった。感謝！

- [マイナビ記事原稿ドラフト 【第1回】サーバレス開発(1)Spring Cloud Functionの実装](http://debugroom.github.io/doc/java/spring/spring-cloud-function/introduction.html)
- http://debugroom.github.io/doc/java/spring/spring-cloud-function/usage.html