3
Help us understand the problem. What are the problem?

posted at

updated at

SpringBoot(+MyBatis)を用いてLambda + APIGatewayのAPIを製造する

SpringBootをLambda関数に実装してAPIGatewayでクライアント側から呼び出すAPIを製造する機会があり、何回か障壁にぶち当たったので、簡単な手順や注意点を備忘録としてまとめておくことにしました。
今回はMyBatisをORマッパーとして採用しましたが、JPAなどでも問題なく同様の手順で製造できるかと思われます。
バックエンド中心の話になります。

今回はAWS CodeStarでLambda+SpringBoot環境を自動生成しています。
 参考URL:https://qiita.com/ogu1101/items/b30a5ccbc28085462dff

pom.xml

Codestarで自動生成されるJavaプロジェクトを使用したため、最初から何点か依存するライブラリの記述は書かれていましたが、私の方で以下を追加しました。

SpringBootのバージョンについて

今回自動生成されたプロジェクトのSpringBootのバージョンが2.0.3でしたが、2.3.0までアップグレードできることを確認しました。
2.4.0以上になると、デプロイ時に失敗してしまいます。
2.3.0にしたい場合はpom.xmlの以下の場所を書き換えます。

pom.xml
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>

        <!-- <version>2.0.3.RELEASE</version> -->
        <version>2.3.0.RELEASE</version>
    </parent>

接続するデータベース

今回はMySQLを使用します。

pom.xml
<dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>8.0.28</version>
</dependency>

Lombokが使えない

私の開発環境では、デプロイ時にエラーが出てしまうためLombokを依存関係に追加することができませんでした・・・。
そのため、@Data@Getter等のアノテーションは使用できなくなります。
(ちなみに、私の開発環境はCodePipelineでコードをコミット&プッシュ時に自動でデプロイされるようになっています)

pom.xml
<!-- <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <version>1.18.22</version>
</dependency> -->

jsonを扱う外部ライブラリの"Gson"

Gsonを使うのでこちらも追加しましょう。
データをJSON形式に成形してくれる便利な外部ライブラリです。

pom.xml
<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.9.0</version>
</dependency>

ビルドプラグイン

buildタグの中をプロジェクトに適したものにしなければ、Lambdaの容量エラーが頻繁に出たり、デプロイしてもAPIが実行できない事態に陥ることになります。
私の場合は以下の通りになっています。

pom.xml
    <build>
        <plugins>
            <!-- maven-shade-plugin -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.2.4</version>
                <configuration>
                    <createDependencyReducedPom>false</createDependencyReducedPom>
                    <transformers>
                        <transformer implementation="com.github.edwgiz.mavenShadePlugin.log4j2CacheTransformer.PluginsCacheFileTransformer">
                        </transformer>
                    </transformers>
                </configuration>
                <executions>
                    <execution>
                        <id>shade-jar</id>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                    </execution>
                </executions>
                <dependencies>
                    <dependency>
                        <groupId >com.github.edwgiz</groupId>
                        <artifactId>maven-shade-plugin.log4j2-cachefile-transformer</artifactId>
                        <version>2.8.1</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>

application.properties

com.mysql.cj.jdbc.Driverをドライバとして使おうとしたのですが、ビルド時にエラーが出てしまうため以下のドライバを使用しています。

application.properties
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

コントローラ(Handler)を追加する

SpringBoot単体のコントローラは以下の通りで記述すれば動作します。(登録が成功すれば"1"という数字をレスポンスします)

OnlySpringBootController.java
@RestController
@RequestMapping("/xxx")

public class OnlySpringBootController {

    // Serviceクラスの依存関係を注入する
    @Autowired
    CreateSomethingService createSomethingService;

    // POSTメソッドを受けステータスをレスポンスする
    @RequestMapping(method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Map<String, Object>> createSomething(
            @RequestBody Dto dto) {

        // Create件数をcountに格納
        int count = createSomethingService.createSomething(careerInformation);

        // 登録件数をレスポンス
        Map<String, Object> map = new HashMap<>();
        map.put("count", count);
        return new ResponseEntity<Map<String, Object>>(map, HttpStatus.OK);
    }
}

それに対して、LambdaでSpringBootを実装してAPIGatewayでクライアントにレスポンスする際には以下の記述が必要となります。
importは省略しています。
LambdaSpringController.java
package com.aws.codestar.example.handler;

@SpringBootApplication
@ComponentScan({ "com.aws.codestar.example.service" })
@EntityScan("com.aws.codestar.example.model")
@MapperScan(basePackages = "com.aws.codestar.example.repository")

public class LambdaSpringController implements RequestHandler<Object, Object> {

    // Serviceクラスの依存関係を注入する
    @Autowired
    CreateSomethingService createSomethingService;

    // リクエスト情報を格納している「input」
    private Object input;

    // 実行関数に関する情報を格納している「context」
    private Context context;

    @Override
    public Object handleRequest(final Object input, final Context context) {

        String args[] = new String[0];

        // SpringApplicationを起動する
        try (ConfigurableApplicationContext ctx = SpringApplication.run(LambdaSpringController.class, args)) {

            LambdaSpringController app = ctx.getBean(LambdaSpringController.class);
            app.setInput(input);
            app.setContext(context);

            // runクラスを実行した結果をresultに格納する
            Object result = app.run(args);
            return result;

        } catch (Exception e) {

            return "error.";
        }
    }

    // POSTメソッドを受けJSONでレスポンス
    public Object run(String... args) throws Exception {

        // JsonをJavaに変換するためのObjectMapper
        ObjectMapper mapper = new ObjectMapper();

        // JavaからJsonに変換するためのGson
        Gson gson = new Gson();
        String json = gson.toJson(input);

        // リクエストのJSONをクラスにマッピング
        RequestBody requestBody= mapper.readValue(json, RequestBody.class);

        // リクエストボディのみをJsonNodeにマッピング
        String body = requestBody.getBody();
        JsonNode root = mapper.readValue(body, JsonNode.class);

        // リクエストボディの値を取得
        JsonNode idNode = root.get("id");
        String id = idNode.asText();

        // リクエストボディの値を取得
        JsonNode nameNode = root.get("name");
        String name = nameNode.asText();

        // 登録件数をcountに格納
        int count = createSomethingService.createSomething(id, name);

        // 登録件数をレスポンス
        Map<String, Object> map = new HashMap<>();
        map.put("count", count);

        // ヘッダーを定義
        Map<String, String> headers = new HashMap<>();
        headers.put("Content-Type", "application/json");
        headers.put("Access-Control-Allow-Headers", "Content-Type");
        headers.put("Access-Control-Allow-Methods", "OPTIONS,POST,GET");
        headers.put("Access-Control-Allow-Origin", "*");

        return new GatewayResponse(map, headers, 200);

    }

    public Object getInput() {
        return input;
    }

    public void setInput(Object input) {
        this.input = input;
    }

    public Context getContext() {
        return context;
    }

    public void setContext(Context context) {
        this.context = context;
    }
}

コードが長くなってしまったので、上から順に見ていきましょう!

ハンドラごとに必要なアノテーション

LambdaSpringController.java
@SpringBootApplication
@ComponentScan({ "com.aws.codestar.example.service" })
@EntityScan("com.aws.codestar.example.model")
@MapperScan(basePackages = "com.aws.codestar.example.repository")

まず、アノテーションでHandlerに情報を付与している部分ですが、各ハンドラごとにSpringBootが一度起動するため、各々に@SpringBootApplicationを付与する必要があります。
@ComponentScan, @EntityScan, @MapperScanに関しては、各クラスをDI(依存性の注入)するために必要となります。
これらをちゃんと書いてあげないと、MapperやServiceを呼び出してくれません・・・(T.T)


inputとcontextをセットしてSpringBootを起動する

LambdaSpringController.java
    // リクエスト情報を格納している「input」
    private Object input;

    // 実行関数に関する情報を格納している「context」
    private Context context;

    @Override
    public Object handleRequest(final Object input, final Context context) {

        String args[] = new String[0];

        // SpringApplicationを起動する
        try (ConfigurableApplicationContext ctx = SpringApplication.run(LambdaSpringController.class, args)) {

            LambdaSpringController app = ctx.getBean(LambdaSpringController.class);
            app.setInput(input);
            app.setContext(context);

            // runクラスを実行した結果をresultに格納する
            Object result = app.run(args);
            return result;

        } catch (Exception e) {

            return "error.";
        }
    }

Objectクラスの変数「input」にはリクエストを送ったクライアントの情報等が格納されていますので、そこからヘッダーやボディ、クエリパラメータのデータなどを取り出すことが可能です。
Lombokが使えないため、GetterとSetterを忘れないようにしましょう。

runメソッドの中身

LambdaSpringController.java
    public Object run(String... args) throws Exception {
        ...

        // 1
        Gson gson = new Gson();
        String json = gson.toJson(input); 

        // 2
        RequestBody requestBody= mapper.readValue(json, RequestBody.class); 
        /**
        * リクエストパラメータを受け取りたい場合は、以下の通り
        * RequestParam requestParam = mapper.readValue(json, RequestParam.class);
        */

        // 3
        String body = requestBody.getBody();
        JsonNode root = mapper.readValue(body, JsonNode.class);

        ...
    }

1,Gsonクラスを用いて送られてきたリクエスト情報(input)をJSONに成形する
2,自作のRequestBodyというDTOクラスを用いて、jsonに成形したもろもろの情報の中のリクエストボディのみ受けとる(この時点では、{body: {"id":123, "name":"abc"}のような形です)
3,キーであるbodyに対応する値のみ取得する({"id":123, "name":"abc"}

唐突に出てきたRequestBodyというクラスですが、このDTOクラスは自分で作成します。↓

RequestBody.java
package com.aws.codestar.example.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import org.springframework.stereotype.Component;

// リクエストボディ用のエンティティ
@Component
@JsonIgnoreProperties(ignoreUnknown = true)
public class RequestBody {

    private String body;

    public String getBody() {
        return body;
    }

    public void setBody(String body) {
        this.body = body;
    }
}

RequestParamは以下の通りです。
RequestParam.java
package com.aws.codestar.example.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.JsonNode;

import org.springframework.stereotype.Component;

// リクエストパラメータ用のエンティティ
@Component
@JsonIgnoreProperties(ignoreUnknown = true)
public class RequestParam {

    private JsonNode queryStringParameters;

    public RequestParam(JsonNode queryStringParameters) {
        this.queryStringParameters = queryStringParameters;
    }

    public RequestParam() {
    }

    public JsonNode getQueryStringParameters() {
        return queryStringParameters;
    }

    public void setQueryStringParameters(JsonNode queryStringParameters) {
        this.queryStringParameters = queryStringParameters;
    }
}

次に、以下の通りにJsonNodeを使用してボディ(あるいはクエリパラメータ)内の値を取得します。 getメソッドの引数内に("id")などのキー名を入れてあげるだけで、値を取得してくれます。便利ですね。
LambdaSpringController.java
    public Object run(String... args) throws Exception {
        ...

        // リクエストボディの値を取得
        JsonNode idNode = root.get("id");
        String id = idNode.asText();

        // リクエストボディの値を取得
        JsonNode nameNode = root.get("name");
        String name = nameNode.asText();

        ...
    }


最後に、取得してこれたデータを何かしらの変数に代入して、なおかつヘッダーを自ら定義してあげてGatewayResponseというクラスを用いてレスポンスします。
LambdaSpringController.java
        // ヘッダーを定義
        Map<String, String> headers = new HashMap<>();
        headers.put("Content-Type", "application/json");
        headers.put("Access-Control-Allow-Headers", "Content-Type");
        headers.put("Access-Control-Allow-Methods", "OPTIONS,POST,GET");
        headers.put("Access-Control-Allow-Origin", "*");

        return new GatewayResponse(map, headers, 200);
        ...
    }

GatewayResponseというクラスは以下の通りです。

GatewayResponse.java
package com.aws.codestar.example;

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

/**
 * POJO containing response object for API Gateway.
 */
public class GatewayResponse {

    private final String body;
    private final Map<String, String> headers;
    private final int statusCode;

    public GatewayResponse(final String body, final Map<String, String> headers, final int statusCode) {
        this.statusCode = statusCode;
        this.body = body;
        this.headers = Collections.unmodifiableMap(new HashMap<>(headers));
    }

    public String getBody() {
        return body;
    }

    public Map<String, String> getHeaders() {
        return headers;
    }

    public int getStatusCode() {
        return statusCode;
    }
}

ServiceクラスとMapperクラス

ServiceとMapperに関しては、SpringBoot単体のAPIを実装するときと同じ書き方で大丈夫です。
Invalid bound statementがどうたら~とエラーで言われている場合はだいたいMapperが原因です。
mapper.xmlファイルでは、resultMapタグまでしっかり書いてマッピングした方がいいです。
@Serviceアノテーションと@Mapperアノテーションもお忘れなく。

最後に

他にもこまごまとしたトラブルによりAPIの実行ができなかったりしたことはありましたが、バックエンド側のソースは以上のように書いてあげれば問題ないと思います。
また、例外の共通処理などにLambdaのLayersという機能が使えますので、JavaでのLayersの実装方法も別記事で残します。

参考にさせていただいたサイト

Register as a new user and use Qiita more conveniently

  1. You can follow users and tags
  2. you can stock useful information
  3. You can make editorial suggestions for articles
What you can do with signing up
3
Help us understand the problem. What are the problem?