この記事はなに?
この記事は 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を追加しておく。
# 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を有効にしている。
build.gradle.kts
最初に、IntelliJ IDEAが生成するテンプレートのGradle Wrapperのバージョンが少し低いので、最新に上げておく(気にならなければ上げなくてもよい)。
この記事を書いた当時の最新バージョンは6.7.1。
$ gradle wrapper --gradle-version 6.7.1
この時点で、 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"
}
}
これを、
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と組み合わせて使っている。
plugins {
application // Gradle Application Plugin
}
application {
mainClassName = "org.example.demo.MainKt" // Class name run task will execute
}
この設定をしたあと、IntelliJ IDEAでGradleのキャッシュを更新すると、 run
というタスクが新しく生成されていることがわかる。
このタスクを実行すると、mainClassName
に指定したクラスのmain
メソッドが実行される。
Kotlinでは、トップレベルの関数を宣言した場合は、デフォルトではファイル名(拡張子を除く)+Kt
というクラスが作成され、内部的にはそのクラス内で宣言した関数が実行される。
したがって、今回はsrc/main/kotlin/org/example/demo/Main.kt
というファイルを作成し、ここにトップレベルのmain
関数を配置することとする。
これにより、内部的にはorg.example.demo.MainKt
のmain
メソッドが呼ばれることになる。
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点、
application {
mainClassName = "org.example.demo.MainKt"
}
という記述は、Gradle 6.7ではDeprecatedになっており、IntelliJ IDEA等では、
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点の問題点がある。
- メインクラスが指定されていない。解凍したJARファイルの内部には
META-INF/MANIFEST.MF
という、JARアーカイブの定義が書かれたファイルがあり、ここにMain-Class
という名前のエントリを追加することで、JARファイルをjava -jar
コマンドで起動した際のメインクラスを指定できる。 - クラスパスにある依存クラス(Kotlinの標準ライブラリやサードパーティ製ライブラリなど)がJARアーカイブに含まれていない。何らかの方法でこれらを含める必要がある。
これらの設定を適用して、そのファイル以外に依存を必要とせず実行可能になったJARファイルは、俗にFat JARとかUber JARと呼ばれている。
Fat JARの作り方はいろいろあるが、比較的簡単なのは、Gradle Shadow Pluginを利用する方法。
https://imperceptiblethoughts.com/shadow/
plugins {
id("com.github.johnrengelman.shadow") version "6.1.0"
}
プラグインをインストールするだけで、shadowJar
やrunShadow
(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: https://ktlint.github.io/
- detekt: https://detekt.github.io/detekt/ (こちらは正確には静的解析ツールと言った方が正しい?)
があるが、自分がよく使うのはktlint。
こちらもGradleのプラグインがあるのであまり何も考えず組み込んでいる。
https://github.com/JLLeitschuh/ktlint-gradle
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
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
}
}
例えば、
package org.example.demo.sample
class Calc(
val origNum: Int
) {
fun add(num: Int): Int {
return origNum + num
}
}
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のモックライブラリで有名なのは、
- Mockito: https://site.mockito.org/
- MockitoはもともとJava向けのライブラリであり、Kotlinと組み合わせる時はmockito-kotlinというブリッジライブラリも依存に含める必要がある https://github.com/nhaarman/mockito-kotlin
- MockK: https://mockk.io/
がある。
Spring Frameworkを使ったサーバサイド開発をする時はMockitoを使うことが多いが(SpringにはMockitoの組み込みサポートがある)、最近素のKotlinを使う時はMockKを使うようにしている。
理由は、Mockitoに比べてKotlinの文法のサポートがしっかりしており、Mockitoで書くと汚くなる部分を比較的きれいに書けるようになるから。
例えば、
package org.example.demo.sample
class Calc2(
private val dependency: Dependency
) {
fun delegate(): Int {
return dependency.calc(1, 2)
}
}
package org.example.demo.sample
interface Dependency {
fun calc(a: Int, b: Int): Int
}
package org.example.demo.sample
class DependencyImpl : Dependency {
override fun calc(a: Int, b: Int): Int {
return a + b
}
}
こんなクラスに対して、
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()) }
}
}
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の内容を変えている。
自分の手元でしか動かさない場合は、
# 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実行環境が頻繁に変わる場合(他人に配るスクリプトなど)については、
# 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の話をする予定だったが、実装する時間が取れなかった…