SpringBoot × Kotlin × Lambda × Terraform × OpenAPI で API を構築してみた

2024.09.02
SpringBoot × Kotlin × Lambda × Terraform × OpenAPI で API を構築してみた
この記事をシェアする

はじめに

この記事では、API 駆動開発の手法を用いて、Spring Boot(Kotlin)と AWS Lambda を組み合わせた API バックエンドの構築方法を紹介します。

せっかくの API 駆動なので、OpenAPI Generator で Kotlin のデータクラスを自動生成します。
さらに、API Gateway の構築にも OpenAPI を使用します。

コード全体はこちらに置いておきます。

OpenAPI で API 設計

最初に、OpenAPI で API を設計を行います。以下の例では、`GET /hello`と`POST /uppercase`の 2 つのエンドポイントが定義されており、それぞれが異なる Lambda 関数を呼び出します。

openapi: 3.0.3
info:
  title: Lambda API
  description: API with multiple Lambda functions using Lambda environment variables for function routing.
  version: 1.0.0
paths:
  /hello:
    get:
      summary: Returns a greeting message
      operationId: hello
      responses:
        '200':
          description: A greeting message
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HelloResponse'
      x-amazon-apigateway-integration:
        uri: arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${region}:${accountId}:function:hello/invocations
        passthroughBehavior: when_no_templates
        httpMethod: POST
        type: aws_proxy
  /uppercase:
    post:
      summary: Converts text to uppercase
      operationId: uppercase
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UppercaseRequest'
      responses:
        '200':
          description: The uppercase version of the input string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UppercaseResponse'
      x-amazon-apigateway-integration:
        uri: arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/arn:aws:lambda:${region}:${accountId}:function:uppercase/invocations
        passthroughBehavior: when_no_templates
        httpMethod: POST
        type: aws_proxy
components:
  schemas:
    HelloResponse:
      type: object
      required:
        - value
      properties:
        value:
          type: string
    UppercaseRequest:
      type: object
      required:
        - input
      properties:
        input:
          type: string
        lengthLimit:
          type: integer
        applyPrefix:
          type: boolean
        prefix:
          type: string
    UppercaseResponse:
      type: object
      required:
        - uppercase
        - original
        - length
      properties:
        uppercase:
          type: string
        original:
          type: string
        length:
          type: integer

この OpenAPI ファイルを元に API Gateway を構築できるのですが、x-amazon-apigateway-integration 拡張を使うことで、API Gateway と Lambda の統合を設定できます。

拡張は他にも色々ある(リンク)ようで、それらを定義すると、モックレスポンスをつくれたり、CORS 設定を加えたりできるみたいです。

OpenAPI Generator を使った Kotlin データクラスの自動生成

`openapi.yaml`ファイルの`components.schemas`に定義されたスキーマをもとに、Kotlin のデータクラスを自動生成します。

ここでは、OpenAPI Generator を使用します。

手順

1. `openapi.yaml` から Kotlin データクラスを生成します。

openapi-generator generate -i openapi/openapi.yaml -g kotlin -o ./generated -c openapi/generator_config.json

2. `config.json`ファイルでカスタマイズ設定を行い、生成されたクラスに`@Serializable`アノテーションを付与することも可能です。

 {
   "serializationLibrary": "kotlinx_serialization",
   "packageName": "com.example"
 }

3. 生成されたクラスをプロジェクトに取り込み、API のリクエストボディやレスポンスボディとして使用します。

生成されるクラス例

`HelloResponse`スキーマに基づいて生成される Kotlin のデータクラスは次のようになります。

`UppercaseRequest`と`UppercaseResponse`も一緒に生成されます。

/**
 *
 * Please note:
 * This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
 * Do not edit this file manually.
 *
 */

@file:Suppress(
    "ArrayInDataClass",
    "EnumEntryName",
    "RemoveRedundantQualifierName",
    "UnusedImport"
)

package com.example.models


import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName

@Serializable
data class HelloResponse (

    @SerialName(value = "value")
    val `value`: kotlin.String

)

そのまま使える形になっていますね!

バックエンドアプリケーションの構成

このセクションでは、Lambda 関数の処理を実装します。

コードについては各自好きなように実装すればいいので、ほとんど省略し、SpringBootApplication クラスと、Lambda のエントリポイントになるプレゼンテーション層の Bean だけ記載します。

package com.example

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class Application

fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

SpringBootApplicationを宣言するところは、通常の SpringBoot のアプリケーションと同じかと思います。

package com.example

import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent
import com.example.models.HelloResponse
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.function.Function

@Configuration
class HelloConfig {

    @Bean
    fun helloFunction(helloService: HelloService): Function<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
        return Function { request ->
            val helloMessage = helloService.getHelloMessage()
            val helloResponse = HelloResponse(value = helloMessage)

            val response = APIGatewayProxyResponseEvent()
            val jsonResponse = Json.encodeToString(helloResponse)
            response.withStatusCode(200)
                .withBody(jsonResponse)
            response
        }
    }
}

Lambda のエントリポイントとしてBeanを登録します。(リンク)

Bean は実際の処理が書かれた`java.util.function.Function`を返します。

引数と戻り値の型は aws-lambda-java-eventsライブラリの型が使えます。

package com.example

import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent
import com.example.models.UppercaseRequest
import com.example.models.UppercaseResponse
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.function.Function

@Configuration
class UppercaseConfig {

    private val json = Json { ignoreUnknownKeys = true }

    @Bean
    fun uppercaseFunction(
        uppercaseService: UppercaseService
    ): Function<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
        return Function { input ->
            val body = input.body
            val request = json.decodeFromString<UppercaseRequest>(body)

            val result = uppercaseService.convertToUppercase(
                input = request.input,
                lengthLimit = request.lengthLimit,
                applyPrefix = request.applyPrefix == true,
                prefix = request.prefix
            )

            val response = UppercaseResponse(
                uppercase = result,
                original = request.input,
                length = result.length
            )

            val responseBody = json.encodeToString(response)

            APIGatewayProxyResponseEvent()
                .withStatusCode(200)
                .withBody(responseBody)
        }
    }
}

JSON のデコードとエンコードには、kotlinx.serializationを使います。

Jackson でももちろん OK です!kotlinx.serialization は Kotlin 標準ということなので、今回採用しました。

build.gradle.kts の設定

Spring Boot と Kotlin で Lambda 用のファット Jar をビルドするための設定を行います。

ファット Jar 生成には、デファクトスタンダードになっているShadow(Shadow Jar)(リンク)を使います。

Shadow は最近(2023 年〜2024 年あたりに)メンテナや Maven 座標が変わったみたいですね。

import com.github.jengelman.gradle.plugins.shadow.transformers.PropertiesFileTransformer
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    // Kotlin必須
    kotlin("jvm") version "2.0.10"

    // SpringBoot使用時に必須・推奨
    // 参考: <https://spring.pleiades.io/guides/tutorials/spring-boot-kotlin>
    id("org.springframework.boot") version "3.3.3"
    id("io.spring.dependency-management") version "1.1.6"
    kotlin("plugin.spring") version "2.0.20"
    kotlin("plugin.jpa") version "2.0.20"

    // Lambdaのために実行可能jarを作るGradleタスクを自動生成する
    // ユーザガイ: <https://gradleup.com/shadow/>
    id("com.gradleup.shadow") version "8.3.0"

    // JSONの処理(Kotlinx Serializer)に必要
    kotlin("plugin.serialization") version "2.0.20"
}

group = "org.example"
version = "0.1"

java {
    toolchain {
        // Javaのバージョン指定はこうする2024年9月現在
        languageVersion.set(JavaLanguageVersion.of(21))
        vendor.set(JvmVendorSpec.AMAZON)
    }
}

repositories {
    mavenCentral()
}

val kotlinxSerializationVersion = "1.3.2"
val springCloudFunctionVersion = "4.1.3"
val awsJavaSdkVersion = "3.13.0"

dependencies {
    // SpringBootで必須
    implementation("org.springframework.boot:spring-boot-starter-web")

    // Spring Cloud FunctionをLambdaで使うのに必須
    implementation("org.springframework.cloud:spring-cloud-function-kotlin:$springCloudFunctionVersion")
    implementation("org.springframework.cloud:spring-cloud-function-adapter-aws:$springCloudFunctionVersion")

    // JSONの処理(Kotlinx Serializer)に必要
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion")

    // API Gatewayとのインタフェースに必要
    implementation("com.amazonaws:aws-lambda-java-events:$awsJavaSdkVersion")

    // テストに必要
    testImplementation(kotlin("test"))
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.shadowJar {
    // 実行可能Jarの末尾にawsと付与される
    archiveClassifier.set("aws")

    // Springに必要なファイルをshadowJarにマージするように指示
    // Springに必須
    mergeServiceFiles()
    append("META-INF/spring.handlers")
    append("META-INF/spring.schemas")
    append("META-INF/spring.tooling")
    append("META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports")
    append("META-INF/spring/org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration.imports")
    transform(PropertiesFileTransformer().apply {
        paths = listOf("META-INF/spring.factories")
        mergeStrategy = "append"
    })
}

tasks.assemble {
    // assembleタスクに実行可能Jar生成を追加
    dependsOn(tasks.shadowJar)
}

tasks.withType<KotlinCompile> {
    compilerOptions {
        // Java側のコードもKotlinでNull安全に扱えるようにする(Java側で対応されていれば)
        freeCompilerArgs.add("-Xjsr305=strict")
    }
}

tasks.test {
    useJUnitPlatform()
}

tasks.withType<Jar> {
    manifest {
        // META-INF/MANIFEST.MFファイルに、Main-Classエントリを設定
        // メインクラスを見つけるために必須
        // Lambdaに環境変数main_classを指定してもよい
        attributes["Main-Class"] = "com.example.ApplicationKt"
    }
}

この記載が無いと、動きそうで動かないみたいな呪文が多いです・・・。

Terraform で AWS リソースを構築

最後に、Terraform を使って Lambda 関数や API Gateway などの AWS リソースを構築します。ここでは、OpenAPI ファイルを利用して API Gateway のリソースを自動的に構築します。

resource "aws_api_gateway_rest_api" "api" {
  name        = var.project
  description = "Example API with multiple Lambda functions"
  body = templatefile("${path.root}/../../openapi/openapi.yaml", {
    region    = data.aws_region.current.name
    accountId = data.aws_caller_identity.self.account_id
  })
}

module "api_handlers" {
  for_each = {
    hello = {
      iam_policy_arns = [aws_iam_policy.hello.arn]
    }
    uppercase = {
      iam_policy_arns = [aws_iam_policy.uppercase.arn]
      memory_size     = 256
  } }

  source = "../modules/api_handler"

  jar_file_path     = "${path.root}/../../backend/build/libs/backend-0.1-aws.jar"
  api_execution_arn = aws_api_gateway_rest_api.api.execution_arn
  operation_id      = each.key
  function_settings = each.value
}

resource "aws_iam_policy" "hello" {
  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Sid      = "HelloSamplePolicy"
      Action   = "logs:*",
      Effect   = "Allow",
      Resource = "*"
      }
      # 個別の権限を設定
    ]
  })
}

resource "aws_iam_policy" "uppercase" {
  policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Sid      = "UppercaseSamplePolicy"
      Action   = "logs:*",
      Effect   = "Allow",
      Resource = "*"
      }
      # 個別の権限を設定
    ]
  })
}
resource "aws_lambda_function" "api_handler" {
  function_name    = var.operation_id
  runtime          = "java21"
  handler          = "org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest"
  role             = aws_iam_role.lambda.arn
  filename         = var.jar_file_path
  source_code_hash = filesha256(var.jar_file_path)
  timeout          = 29
  memory_size      = var.function_settings.memory_size

  environment {
    variables = {
      spring_cloud_function_definition = "${var.operation_id}Function"
    }
  }
}

resource "aws_lambda_permission" "api_handler" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.api_handler.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${var.api_execution_arn}/*/*/${var.operation_id}"
}

resource "aws_iam_role" "lambda" {
  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Action = "sts:AssumeRole",
      Effect = "Allow",
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic_execution" {
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

resource "aws_iam_role_policy_attachment" "lambda" {
  for_each = toset(var.function_settings.iam_policy_arns)

  role       = aws_iam_role.lambda.name
  policy_arn = each.key
}

Lambda のエントリポイントになる Bean は、spring_cloud_function_definition 環境変数で指定します。(リンク)

この方法以外にも、ルーティング関数を実装したり、HTTP ヘッダー(spring.cloud.function.definition)で指定することもできます。

動作確認

curl で API を呼び出して、動作確認します。

curl -X GET https://{api_id}.execute-api.{region}.amazonaws.com/{stage}/hello
{
  "value": "Hello, World!"
}
curl -X POST https://{api_id}.execute-api.{region}.amazonaws.com/{stage}/uppercase \
  -H "Content-Type: application/json" \
  -d '{"input": "hello", "lengthLimit": 5, "applyPrefix": true, "prefix": "PREFIX_"}'
{
  "uppercase": "PREFIX_HELLO",
  "original": "hello",
  "length": 11
}

まとめ

無事動きました。

やはり Gradle の設定がややこしいですね。どんな言語も依存関係の管理は大変かとは思いますが。また、フレームワークと Lambda の噛み合わせもやっぱり一筋縄では行かないですよね。あと、今後の課題ですが、コールドスタートがやはりゲキ遅です。Lambda SnapStartは必須なのかもしれないです。

Spring Cloud Function の Kotlin での例が少なかったので、本記事を投稿しました。参考になれば幸いです。

この記事をシェアする
著者:酒井亮太郎
シナモロール