21
9

More than 3 years have passed since last update.

Kotlinでスクリプトを書き始める時に使っているテンプレートについて紹介する

Last updated at Posted at 2020-12-13

この記事はなに?

この記事は Kotlin Advent Calender 2020 14日目の記事です。

以前からKotlin/JVMをサーバサイド開発に使っていますが、最近は簡単なスクリプトを書くときにもKotlinを使うことが増えました(前はよくPythonを使っていましたが、開発チーム的に言語をなるべく揃えた方がよさそうなので…)。
毎回イチからコードを書くのは面倒なので、とりあえず自分の中でストレスなく動かし始められる状態にしたテンプレートをGitHubにおいています。

この記事では、このテンプレートでどんなものを使っているか紹介していきます。

環境

  • macOS 10.15
  • JDK 11
  • Kotlin 1.4
  • Gradle 6.7.1
  • Docker 19
  • IntelliJ IDEA Community 2020.1

テンプレート

ここにある。
https://github.com/lethe2211/kotlin-gradle-sample

必要に応じて随時更新していくつもり。

やりたかったこと

  • できるだけ早く動かせる状態にしたい。毎回必要な依存を考えて入れたくないし、コマンド1つで起動やビルドができる状態にしておきたい
  • Kotlinの開発にはIntelliJ IDEAをよく使うので、支障なく動くようにしておきたい
  • ソースコードはGit管理したい
  • Docker/Kubernetesからすぐに利用できるようにしておきたい
  • でも明らかに不要なものは入れたくない。デバッグが難しくなるため

.gitignore

基本的にスクリプトはGit管理する前提なので、最初に.gitignoreを追加しておく。

.gitignore
# Gradle
.gradle
/build/
!gradle/wrapper/gradle-wrapper.jar

# IntelliJ IDEA
.idea
*.iws
*.iml
*.ipr
/out/

.gitignoreと、実際にどのファイルをGit管理下に置くべきかについては理解が足りていない部分も多いので、必要に応じて管理するファイルを増やしたり減らしたりしている…

ついでに、ここでGit管理を開始しておく。


$ git init

$ git add .gitignore && git commit -m 'Initial commit'

Gradleの設定

Kotlinでアプリケーションを書くときはビルドツールにGradleを使うことが多い(Mavenは正直あまり使ったことがない)。
ベースはIntelliJ IDEAのGradleテンプレートを使っている。

プロジェクト作成時、以下のように、Gradle Kotlin DSLを有効にしている。

image.png

build.gradle.kts

最初に、IntelliJ IDEAが生成するテンプレートのGradle Wrapperのバージョンが少し低いので、最新に上げておく(気にならなければ上げなくてもよい)。
この記事を書いた当時の最新バージョンは6.7.1。

$ gradle wrapper --gradle-version 6.7.1

この時点で、 build.gradle.kts は以下のようになっている。

build.gradle.kts
plugins {
    kotlin("jvm") version "1.4.0"
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib-jdk8"))
}

tasks {
    compileKotlin {
        kotlinOptions.jvmTarget = "1.8"
    }
    compileTestKotlin {
        kotlinOptions.jvmTarget = "1.8"
    }
}

これを、

build.gradle.kts
plugins {
    kotlin("jvm") version "1.4.0"
    application

    // Shadow Plugin (to generate a Fat JAR)
    id("com.github.johnrengelman.shadow") version "6.1.0"

    // ktlint Plugin
    id("org.jlleitschuh.gradle.ktlint") version "9.4.1"
}

group = "org.example"
version = "1.0-SNAPSHOT"
repositories {
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib-jdk8"))

    // JUnit 5
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")

    // MockK
    testImplementation("io.mockk:mockk:1.10.3")
}

application {
    mainClassName = "org.example.demo.MainKt"
}

tasks {
    compileKotlin {
        kotlinOptions.jvmTarget = "1.8"
    }
    compileTestKotlin {
        kotlinOptions.jvmTarget = "1.8"
    }
    test {
        useJUnitPlatform() // Enable JUnit 5 support of Gradle
    }
}

こんな感じに変更した。
以下、それぞれの変更内容について見ていくこととする。

Gradle Application Plugin

Gradle Application Pluginは、JVMアプリケーションのローカル実行やビルドを補助するためのプラグイン。
Javaアプリケーションと組み合わせることも多いが、今回はKotlinと組み合わせて使っている。

build.gradle.kts
plugins {
    application // Gradle Application Plugin
}

application {
    mainClassName = "org.example.demo.MainKt" // Class name run task will execute
}

この設定をしたあと、IntelliJ IDEAでGradleのキャッシュを更新すると、 run というタスクが新しく生成されていることがわかる。

image.png

このタスクを実行すると、mainClassNameに指定したクラスのmainメソッドが実行される。

Kotlinでは、トップレベルの関数を宣言した場合は、デフォルトではファイル名(拡張子を除く)+Ktというクラスが作成され、内部的にはそのクラス内で宣言した関数が実行される。

したがって、今回はsrc/main/kotlin/org/example/demo/Main.ktというファイルを作成し、ここにトップレベルのmain関数を配置することとする。
これにより、内部的にはorg.example.demo.MainKtmainメソッドが呼ばれることになる。

src/main/kotlin/org/example/demo/Main.kt
package org.example.demo

fun main(args: Array<String>) {
    println("Hello World")
}

IntelliJ IDEAでGradleのrunタスクを実行すると、Hello Worldが出力されることが確認できる。

Application Pluginは、内部的にGradle Java Pluginと、Gradle Distribution Pluginを含んでいる。
詳しい挙動が知りたい際には、これらのプラグインのドキュメントも参考になる。

Java Plugin: https://docs.gradle.org/current/userguide/java_plugin.html#java_plugin
Distribution Plugin: https://docs.gradle.org/current/userguide/distribution_plugin.html#distribution_plugin

1点、

build.gradle.kts
application {
    mainClassName = "org.example.demo.MainKt"
}

という記述は、Gradle 6.7ではDeprecatedになっており、IntelliJ IDEA等では、

build.gradle.kts
application {
    mainClass.set("org.example.demo.MainKt")
}

に変更するように促される。

古い記述を利用している理由は、後述するGradle Shadow Pluginとの互換性の問題のため。
https://github.com/johnrengelman/shadow/issues/609

おそらく今後のShadow Pluginのアップデートで修正されると思われるが、いまはこのままにしておく。

Fat JARを作るための設定

ここまでの状態でGradleのcleanタスク、bulidタスクをこの順で実行すると、build/libs/${PROJECT_NAME}-1.0-SNAPSHOT.jarという名前の実行可能JARファイルが作成される。

ただし、このJARファイルを実行しようとすると、

$ java -jar build/libs/gradle-kotlin-sample-1.0-SNAPSHOT.jar
no main manifest attribute, in build/libs/gradle-kotlin-sample-1.0-SNAPSHOT.jar

というエラーが出て実行できない。
JARのアーカイブ時の設定を見直すことで、生成されるJARファイルがそのまま実行可能なものにする。

まず、この状態のJARを展開してみる(JARファイルはZIP形式でアーカイブされているので、ZIP形式に対応した解凍ツールで解凍することで中身を見ることができる)。

$ unzip build/libs/gradle-kotlin-sample-1.0-SNAPSHOT.jar -d build/libs/gradle-kotlin-sample/
Archive:  build/libs/gradle-kotlin-sample-1.0-SNAPSHOT.jar
   creating: build/libs/gradle-kotlin-sample/META-INF/
  inflating: build/libs/gradle-kotlin-sample/META-INF/MANIFEST.MF
   creating: build/libs/gradle-kotlin-sample/org/
   creating: build/libs/gradle-kotlin-sample/org/example/
   creating: build/libs/gradle-kotlin-sample/org/example/demo/
  inflating: build/libs/gradle-kotlin-sample/org/example/demo/MainKt.class
  inflating: build/libs/gradle-kotlin-sample/META-INF/gradle-kotlin-sample.kotlin_module

$ cat build/libs/gradle-kotlin-sample/META-INF/MANIFEST.MF
Manifest-Version: 1.0

大きく分けて、2点の問題点がある。

  1. メインクラスが指定されていない。解凍したJARファイルの内部にはMETA-INF/MANIFEST.MFという、JARアーカイブの定義が書かれたファイルがあり、ここにMain-Classという名前のエントリを追加することで、JARファイルをjava -jarコマンドで起動した際のメインクラスを指定できる。
  2. クラスパスにある依存クラス(Kotlinの標準ライブラリやサードパーティ製ライブラリなど)がJARアーカイブに含まれていない。何らかの方法でこれらを含める必要がある。

これらの設定を適用して、そのファイル以外に依存を必要とせず実行可能になったJARファイルは、俗にFat JARとかUber JARと呼ばれている。

Fat JARの作り方はいろいろあるが、比較的簡単なのは、Gradle Shadow Pluginを利用する方法。
https://imperceptiblethoughts.com/shadow/

build.gradle.kts
plugins {
    id("com.github.johnrengelman.shadow") version "6.1.0"
}

プラグインをインストールするだけで、shadowJarrunShadow(Gradle Application Pluginと一緒にインストールした場合のみ)といった新しいGradleのタスクが生成される。

例えば、IntelliJ IDEAでGradleのshadowJarタスクを実行するとbuild/libs/以下にgradle-kotlin-sample-1.0-SNAPSHOT-all.jarのようなFat JARが生成される。

$ ls -l build/libs/gradle-kotlin-sample-1.0-SNAPSHOT-all.jar

$ unzip build/libs/gradle-kotlin-sample-1.0-SNAPSHOT-all.jar -d build/lib
s/gradle-kotlin-sample/
Archive:  build/libs/gradle-kotlin-sample-1.0-SNAPSHOT-all.jar
   creating: build/libs/gradle-kotlin-sample/META-INF/
  inflating: build/libs/gradle-kotlin-sample/META-INF/MANIFEST.MF
   creating: build/libs/gradle-kotlin-sample/org/
   creating: build/libs/gradle-kotlin-sample/org/example/
   creating: build/libs/gradle-kotlin-sample/org/example/demo/
  inflating: build/libs/gradle-kotlin-sample/org/example/demo/MainKt.class
  inflating: build/libs/gradle-kotlin-sample/META-INF/gradle-kotlin-sample.kotlin_module
  inflating: build/libs/gradle-kotlin-sample/META-INF/kotlin-stdlib-jdk8.kotlin_module
   creating: build/libs/gradle-kotlin-sample/kotlin/
  ...

$ cat build/libs/gradle-kotlin-sample/META-INF/MANIFEST.MF
Manifest-Version: 1.0
Main-Class: org.example.demo.MainKt

$ java -jar build/libs/gradle-kotlin-sample-1.0-SNAPSHOT-all.jar
Hello World

プラグインを適用すると、buildからshadowJarへ自動的に依存が張られるので、特に気にならなければbuildタスクを使うとよい。

JARアーカイブについての詳しい仕様は以下を参照。
https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html

ktlint(Linter)

KotlinのLinterで有名なものとして、

があるが、自分がよく使うのはktlint。

こちらもGradleのプラグインがあるのであまり何も考えず組み込んでいる。
https://github.com/JLLeitschuh/ktlint-gradle

build.gradle.kts
plugins {
    id("org.jlleitschuh.gradle.ktlint") version "9.4.1"
}

このプラグインもインストール時にGradleにいろいろなタスクを生やす。
よく使うのが、

  • ktlintCheck: 現在のGradleプロジェクト(に含まれているすべてのSource SetとKotlin Script)について、ktlintを使った文法チェックを行う。失敗した場合はエラー終了する
  • ktlintFormat: 現在のGradleプロジェクト(に含まれているすべてのSource SetとKotlin Script)について、ktlintを使った自動フォーマットを行う。一部自動で修正できないものがあり(ワイルドカードimportなど)、見つけた場合はエラー終了する。Gitの毎コミット前にとりあえずこれを流しておくイメージ
  • ktlintApplyToIdea: ktlintのルールをIntelliJ IDEAに反映させる。.ideaを書き換えるので注意

あたり。

これも、buildタスク(正確にはcheckタスク)にktlintの文法チェックタスクへの依存が張られ、文法チェックが失敗するとビルドが落ちるようになるので、CIの用途にも使える。

JUnit 5

単体テストにはJUnit 5を使っている。

GradleにはネイティブでJUnit 5をサポートする機能があるので、ついでにこれも有効化しておく。
https://docs.gradle.org/current/userguide/java_testing.html#using_junit5

build.gradle.kts
dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter-api:5.6.0")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
}

tasks {
    test {
        useJUnitPlatform() // Enable JUnit 5 support of Gradle
    }
}

例えば、

src/main/kotlin/org/example/demo/sample/Calc.kt
package org.example.demo.sample

class Calc(
    val origNum: Int
) {
    fun add(num: Int): Int {
        return origNum + num
    }
}
src/test/kotlin/org/example/demo/sample/CalcTest.kt
package org.example.demo.sample

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

internal class CalcTest {
    lateinit var calc: Calc

    @BeforeEach
    fun setUp() {
        calc = Calc(2)
    }

    @Test
    fun `add can return the summation of origNum and num`() {
        val expected = 6
        assertEquals(expected, calc.add(4))
    }
}

こんなコードと単体テストを書いて、Gradleのtestタスクを実行すると、IntelliJ IDEA上でテスト結果が見られる。
これも、buildタスクに依存が張られるのでCI用途に使える。

MockK

単体テストにおいて、テスト対象となるクラスが他のクラスに依存している場合(移譲など)、依存するクラスをモックできると便利な場合がある。

Kotlinのモックライブラリで有名なのは、

がある。

Spring Frameworkを使ったサーバサイド開発をする時はMockitoを使うことが多いが(SpringにはMockitoの組み込みサポートがある)、最近素のKotlinを使う時はMockKを使うようにしている。
理由は、Mockitoに比べてKotlinの文法のサポートがしっかりしており、Mockitoで書くと汚くなる部分を比較的きれいに書けるようになるから。

例えば、

src/main/kotlin/org/example/demo/sample/Calc2.kt
package org.example.demo.sample

class Calc2(
    private val dependency: Dependency
) {
    fun delegate(): Int {
        return dependency.calc(1, 2)
    }
}
src/main/kotlin/org/example/demo/sample/Dependency.kt
package org.example.demo.sample

interface Dependency {
    fun calc(a: Int, b: Int): Int
}
src/main/kotlin/org/example/demo/sample/DependencyImpl.kt
package org.example.demo.sample

class DependencyImpl : Dependency {
    override fun calc(a: Int, b: Int): Int {
        return a + b
    }
}

こんなクラスに対して、

src/test/kotlin/org/example/demo/sample/Calc2Test.kt
package org.example.demo.sample

import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.junit5.MockKExtension
import io.mockk.verify
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(MockKExtension::class)
internal class Calc2Test {
    lateinit var calc2: Calc2

    @MockK
    lateinit var dependency: Dependency

    @BeforeEach
    fun setUp() {
        calc2 = Calc2(dependency)
    }

    @Test
    fun `delegate can return the value of Dependency#calc`() {
        // Mock the behavior
        every { dependency.calc(any(), any()) } returns Int.MAX_VALUE

        val expected = Int.MAX_VALUE

        assertEquals(expected, calc2.delegate())

        // Verify if the mocked method was called
        verify(exactly = 1) { dependency.calc(any(), any()) }
    }

    @Test
    fun `delegate can throw an IllegalStateException when Dependency#calc throws an IllegalStateException`() {
        // Mock the behavior
        every { dependency.calc(any(), any()) } throws IllegalStateException("Unexpected exception.")

        // Assert that an IllegalStateException was really thrown
        assertThrows<IllegalStateException> {
            calc2.delegate()
        }

        // Verify if the mocked method was called
        verify(exactly = 1) { dependency.calc(any(), any()) }
    }
}
src/test/kotlin/org/example/demo/sample/DependencyImplTest.kt
package org.example.demo.sample

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

internal class DependencyImplTest {
    lateinit var dependency: Dependency

    @BeforeEach
    fun setUp() {
        dependency = DependencyImpl()
    }

    @Test
    fun `calc can return the summation of a and b`() {
        val expected = 3

        assertEquals(expected, dependency.calc(1, 2))
    }
}

こんなテストが書ける。

Dockerfile

最近は作ったスクリプトをDockerコンテナ化することも多いので、Dockerfileも用意している。

用途に応じてDockerfileの内容を変えている。

自分の手元でしか動かさない場合は、

Dockerfile
# Note that you need a build before running this Dockerfile

FROM adoptopenjdk:11-jre-hotspot
WORKDIR /opt/app
COPY build/libs/gradle-kotlin-sample-1.0-SNAPSHOT-all.jar /opt/app
ENTRYPOINT ["java", "-jar", "/opt/app/gradle-kotlin-sample-1.0-SNAPSHOT-all.jar"]

のように、JARファイルのビルドを前提としたDockerfileを置いておき、README等にビルドの手順を記載している。
スクリプトを編集するたびに、JARファイルのビルド -> Dockerイメージのビルドという手順を繰り返す必要があるが、JARファイルのビルドをGradleに任せるとキャッシュがきくので早いという利点がある。

逆に、Docker実行環境が頻繁に変わる場合(他人に配るスクリプトなど)については、

Dockerfile
# Store Gradle cache so that it won't download the dependencies again and again
# This stage takes a little long time if you run it for the first time
FROM gradle:6.7.1-jdk11 AS cache
WORKDIR /opt/app
ENV GRADLE_USER_HOME /cache
COPY build.gradle.kts gradle.properties settings.gradle.kts ./
RUN gradle --no-daemon build --stacktrace

# Build stage
FROM gradle:6.7.1-jdk11 AS builder
WORKDIR /opt/app
COPY --from=cache /cache /home/gradle/.gradle
COPY . .
RUN gradle --no-daemon build --stacktrace
RUN ls -l /opt/app/build/libs/

# Runtime stage
FROM adoptopenjdk:11-jre-hotspot
WORKDIR /opt/app
COPY --from=builder /opt/app/build/libs/gradle-kotlin-sample-1.0-SNAPSHOT-all.jar /opt/app
ENTRYPOINT ["java", "-jar", "/opt/app/gradle-kotlin-sample-1.0-SNAPSHOT-all.jar"]

のように、JARファイルのビルドをDockerfile中のステージに含めたDockerfileを置いている。
この方が手作業でのビルドを前提としない分きれいだが、初回のdocker buildの際にGradleの依存のキャッシュを貯める時間がかかる(数分~数十分)。2回目からはDockerのイメージキャッシュが利用されるので早くなる。

感想

  • こういうテンプレートを言語ごとに作っておきたい
  • ホントはSpring Cloud Busの話をする予定だったが、実装する時間が取れなかった…

参考

21
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
9