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 での例が少なかったので、本記事を投稿しました。参考になれば幸いです。