Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

KotlinアプリケーションをGoogle Cloud Functionsにデプロイする

Bill One事業部の山邊です。 2020 年 5 月に Google Cloud Functions が Java11 に対応した。それによって JVM 言語で Cloud Functions を記述することができるようになった。

私も開発に携わっているクラウド請求書受領サービスの 「Bill One」(https://bill-one.com/) ではサーバーサイド言語として主に Kotlin を使用してる。しかし、これまでは Cloud Functions を記述する際は主に JavaScript を利用していた。最近になって Kotlin(JVM 言語)の方が都合が良い要件があったため、 Kotlin で Cloud Functions を記述した。

その際、Kotlin 且つ日本語のドキュメントで全体を通して書かれている記事がなかったため、基本的な実装とテストコードの記述についてまとめたいと思う。

環境とライブラリ

Kotlin を書いたことがあるエンジニアであれば build.gradle.kts を示せば依存関係を理解してくれると信じているので build.gradle.kts を示す。

build.gradle.kts

val kotlinVersion = "1.4.20"
val invoker by configurations.creating

plugins {
    id("org.jetbrains.kotlin.jvm") version "1.4.20"
    id("com.github.johnrengelman.shadow") version "6.0.0"
    application
}

repositories {
    jcenter()
}

dependencies {
    // Kotlin
    implementation(platform("org.jetbrains.kotlin:kotlin-bom"))
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion")
    implementation("org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion")
    implementation("io.github.microutils:kotlin-logging:1.11.5")

    // Cloud Function
    compileOnly("com.google.cloud.functions:functions-framework-api:1.0.1")
    invoker("com.google.cloud.functions.invoker:java-function-invoker:1.0.0-alpha-2-rc5")

    // Test
    testImplementation("com.google.cloud.functions:functions-framework-api:1.0.1")
    testImplementation("junit:junit:4.12")
    testImplementation("org.assertj:assertj-core:3.16.1")
    testImplementation("io.mockk:mockk:1.10.0")
    testImplementation("io.github.orangain.json-fuzzy-match:json-fuzzy-match:0.3.1")
}

application {
    mainClassName = "sample.App"
}

// 以下はCloudFunctionにデプロイするためのコード
// 詳細は後述する
task<JavaExec>("runFunction") {
    main = "com.google.cloud.functions.invoker.runner.Invoker"
    classpath(invoker)
    inputs.files(configurations.runtimeClasspath, sourceSets["main"].output)
    args(
            "--target", project.findProperty("runFunction.target") ?: "sample.App",
            "--port", project.findProperty("runFunction.port") ?: 8080
    )
    doFirst {
        args("--classpath", files(configurations.runtimeClasspath, sourceSets["main"].output).asPath)
    }
}

tasks.named("build") {
    dependsOn(":shadowJar")
}

task("buildFunction") {
    dependsOn("build")
    copy {
        from("build/libs/" + rootProject.name + "-all.jar")
        into("build/deploy")
    }
}

モックライブラリ

GCP のドキュメントではモックライブラリには mockito を使用している。しかし、 Kotlin でモックする際は Java 用に作られた mockito よりmockkの方が良い。

JUnit4

テストライブラリには JUnit4 を使用している。知っての通り、JUnit5 がすでにリリースされており、機能としても JUnit5 が優れているため、特に事情がない場合は JUnit5 を使った方が良いだろう。 我々の開発環境だと開発時のログが JUnit4 の方が見やすいのでまだ導入していない。

shadowJar

shadowJar は依存ライブラリをまとめて一つの jar ファイルを作成してくれる。デプロイするファイルを一つにまとめることでクラスパスの列挙が必要なくなる。 shadowJar を利用しない場合は複数の Jar ファイルの依存関係パスを示すリストを用意する必要がある。詳細はドキュメントを参照

ディレクトリ構成

|- src
  |- main
    |- kotlin
      |- sample
        |- App.kt
  |- test
    |- kotlin
      |- sample
        |- AppTest.kt

実際はデプロイ用のシェルスクリプトや環境変数周りのファイルなどを分離しているが、今回の本質ではないため省略し、最もシンプルな形のアプリケーションをサンプルとして示す。

Main Function

方針

Cloud Functions を実装していく中で重要なのはシンプルに小分けすることだと思っている。Bill One では各 Function には多くても数個のエンドポイントのみを提供し、機能毎に Function を分離している。

そのため、今回はルーティング周りの詳細な設計は行っておらず、必要性もあまり感じていない。大きな Function を用意する必要がある場合はその辺りの再設計は必要だと思う。

コード

以下のコードはクエリパラメータで userId と taskId を POST で受け取りタスクを実行しているサンプルコードである。

class App : HttpFunction {
    companion object {
        private val logger = Logger.getLogger(App::class.java.name)
    }

    @Throws(IOException::class)
    override fun service(request: HttpRequest, response: HttpResponse) {
        try {
            when (request.path) {
                "/task-run" -> {
                    if (request.method == "POST") {
                        val requestParam = RequestParam.fromHttpRequest(request)
                        if (requestParam == null) {
                            response.setStatusCode(HttpURLConnection.HTTP_BAD_REQUEST)
                            return
                        }

                        val result = taskManager.run(requestParam.userId, requestParam.taskId) // 適当なメソッドにuserID/taskIDを渡したら結果を返すことを想定
                        response.writer.write(reuslt.toJson) // 適当なJsonMapperでJsonに変換
                        response.setStatusCode(HttpURLConnection.HTTP_OK)
                        return
                    } else {
                        response.setStatusCode(HttpURLConnection.HTTP_BAD_METHOD)
                        return
                    }
                }

                else -> {
                    response.setStatusCode(HttpURLConnection.HTTP_NOT_FOUND)
                    return
                }
            }
        } catch (e: Error) {
            logger.severe(e.message)
            response.setStatusCode(HttpURLConnection.HTTP_INTERNAL_ERROR)
            return
        }
    }
}

data class RequestParam(
        val userId: String,
        val taskId: String
) {
    companion object {
        fun fromHttpRequest(request: HttpRequest): RequestParam? {
            val userId = request.getFirstQueryParameter("userId").orElse("")
            val taskId = request.getFirstQueryParameter("taskId").orElse("")
            if (userId.isBlank() || taskId.isBlank()) return null

            return RequestParam(userId, taskId)
        }
    }
}

パラメータの受け取り

今回はクエリパラメータを受け取る形式のサンプルコードを示した。クエリパラメータにすることでログが確認しやすくなるからだ。 もちろんマルチパート形式など、他の形式でも受け取ることができる。

詳細については公式ドキュメント参照。 Google Cloud Functions: HTTP 関数

エラーハンドリング

今回使用している functions-framework-api は非常に軽量なフレームワークである。その一方でルーティングなどはプログラマが記述する必要がある。これはエラーハンドリングについても同じである。

小さな Functions の場合はルーティングの詳細な設計はあまり重要ではないと前述したが、エラーハンドリングについてはある程度しっかりと行った方がよいと個人的には思っている。

その表れとしてresponse.setStatusCode()で適切なステータスコードを返却している。これにより、ログを見ることが楽になる。 もしも、必要性を感じない場合は全てHTTP_INTERNAL_ERROR を返しておけばいいだろう。

ビルドとデプロイとローカル実行

build.gradle.kts の後半にビルドとローカル実行に必要なコードが記述されている。デプロイは gcloud コマンドを使えば行える。

task<JavaExec>("runFunction") {
    main = "com.google.cloud.functions.invoker.runner.Invoker"
    classpath(invoker)
    inputs.files(configurations.runtimeClasspath, sourceSets["main"].output)
    args(
            "--target", project.findProperty("runFunction.target") ?: "sample.App",
            "--port", project.findProperty("runFunction.port") ?: 8080
    )
    doFirst {
        args("--classpath", files(configurations.runtimeClasspath, sourceSets["main"].output).asPath)
    }
}

tasks.named("build") {
    dependsOn(":shadowJar")
}

task("buildFunction") {
    dependsOn("build")
    copy {
        from("build/libs/" + rootProject.name + "-all.jar")
        into("build/deploy")
    }
}

ビルド

デプロイを行うためにはビルドを行う必要がある。以下のビルドコマンドを実行すれば build/deploy/sampleApp-all.jar が生成される。

./gradlew buildFunction

shadowJarは依存ライブラリを一つの jar ファイルにまとめてくれる便利なライブラリである。必ず使う必要はないが、たくさんファイルが生成されるとその分デプロイに時間がかかりそうなので、使用している。

デプロイ

デプロイには一般的な gcloud コマンドを用いる。いかにサンプルコマンドを記述するが、詳細についてはドキュメントに詳しく書かれているためそちらを参照してほしい。

gcloud functions deploy sampleApp \
  --project sampleProject \
  --region asia-northeast1 \
  --source=build/deploy \
  --trigger-http \
  --runtime=java11 \
  --entry-point=sample.App

ローカル実行

開発時にローカルで Functions を起動したいケースはあると思う。その時は以下のコマンドで起動することができる。ポートは 8080 にしているので、必要に応じて適当に変える必要があるかもしれない。

./graldew runFunction

個人的な意見としてはローカル実行はテスト時のみに留めた方が良いと思っている。他のアプリケーションの開発時に必要な場合はプロダクトとは別に開発用の Function を用意する方が良いだろう。 そうでないとローカルで数多くの Functions を動かす必要が出てくる。

参照

https://github.com/GoogleCloudPlatform/functions-framework-java
https://codenerve.com/creating-google-cloud-functions-in-kotlin/index.html

テスト

テストの手法はいくつもある。GCP のドキュメントでは以下の 3 つの一般的なテストについて説明がされている。

  • 単体テスト
    • テストフレームワークとモックフレームワークを合わせて関数の結果と期待値を比較することで関数の動作を確認します。
  • 統合テスト
    • 単体テストよりもモックが少なくなります。
    • 統合テストでは、HTTP リクエスト、Pub/Sub メッセージ、Storage オブジェクトの変更などの Cloud イベントをトリガーして応答する必要があります。
  • システムテスト
    • 独立したテスト環境で複数の Google Cloud コンポーネントにわたる Cloud Functions の関数の動作を検証します。

ほとんどの場合はこれらを使い分ければ十分なテストを行えると思う。

今回のテストコード

今回は単体テストを導入した。理由は実行時間が短く、他のテストを導入するほどのモチベーションは無かったためだ。前述したとおり、小分けにされた Functions はデプロイすると変更が必要になる頻度は少ない。そのため、システムテストの必要性を感じなかった。 同じ理由で CI/CD も今回の Function には適用していない。

@RunWith(JUnit4::class)
class AppTest {
    @Test
    fun `テストコード`() {
        // リクエストのモック
        val mockRequest = mockk<HttpRequest>()
        every { mockRequest.method } returns "POST"
        every { mockRequest.path } returns "/task-run"
        every { mockRequest.getFirstQueryParameter("userId") } returns Optional.of("user001")
        every { mockRequest.getFirstQueryParameter("taskId") } returns Optional.of("task123")

        // レスポンスのモック
        val mockResponse = mockk<HttpResponse>(relaxed = true)
        val responseOut = StringWriter()
        val statusCode = slot<Int>()

        // 実際に関数を実行
        BufferedWriter(responseOut).use { writerOut ->
            every { mockResponse.writer } returns writerOut
            every { mockResponse.setStatusCode(capture(statusCode)) } just Runs

            App().service(mockRequest, mockResponse)
        }

        // 確認
        Assertions.assertThat(statusCode.captured).isEqualTo(200)
        JsonStringAssert.assertThat(responseOut.toString()).jsonMatches(expectedJson)
    }
}

基本はモック

見ての通り、テストのほとんどがモックである。リクエストパラメータが多くなったら大変だなぁと思いながらコードを書いた。より良いコードは間違いなくあるだろう。 そして、単体テストではモックを多用することからモックライブラリはより使いやすいものを採用した方が良さそうである。

まとめ

コーディングの手軽さでは JavaScript に劣る。しかし、Kotlin は静的型付けでコンパイラでエラーを検知でき、Collection の機能が JavaScript・Java よりも充実している。そういった強力な言語仕様の点では JavaScript は勝てないだろう。 非常に軽微な Functions であれば JavaScript で十分かもしれないが、今後は Kotlin でも Cloud Functions をどんどん書いていきたいと思う。


buildersbox.corp-sansan.com buildersbox.corp-sansan.com hrmos.co

© Sansan, Inc.