エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Kotlin + SpringBoot で構築したアプリケーションの構成をbuild.gradleに沿って紹介

こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。 この記事は エムスリー Advent Calendar 2019 の11日目の記事です。 昨日は大垣の 行動ログデータからのユーザーアンケート予測モデルを作り、ユーザーの嗜好分類をする でした。

今回も中村の記事で宣言した 「医師版Stack Overflow」(仮名)の技術的チャレンジのうち、 Kotlin + SpringBoot でのアプリケーション構成例を build.gradle に沿ってご紹介します*1

f:id:fukubaya:20191206210308j:plain
山中湖交流プラザ きららは山梨県山中湖村にある総合公園。本文には特に関係ありません。

DBフレームワーク

MyBatis3

DBの入出力には、Kotlinで書かれているJetBrains製のExposedを検討していましたが、 細かい制御がまだ難しそうというアドバイスもあり、 MyBatis3を使うことにしました。

mybatis.org

// build.gradle

buildscript {
    ext {
        myBatisSpringBootVersion = '2.1.1'
    }
}

dependencies {
    // mybatis
    compile("org.mybatis.spring.boot:mybatis-spring-boot-starter:${myBatisSpringBootVersion}")
    compile("org.mybatis:mybatis-typehandlers-jsr310:1.0.2")
}

XMLはできる限り書きたくないのでアノテーションで書いています。 一部機能に制限があります*2が、 動的にSQLを構成することもできますし、すべてKotlin側に書けて、XMLとKotlinコードを行ったり来たりしなくて済みます。

package com.m3.foobar.adapter.gateway.db

import com.m3.foobar.adapter.gateway.db.model.DbFoo
import org.apache.ibatis.annotations.Mapper
import org.apache.ibatis.annotations.Param
import org.apache.ibatis.annotations.Result
import org.apache.ibatis.annotations.ResultMap
import org.apache.ibatis.annotations.Results
import org.apache.ibatis.annotations.SelectProvider
import org.apache.ibatis.builder.annotation.ProviderMethodResolver
import org.apache.ibatis.jdbc.SQL

@Mapper
interface FooMapper {
    /**
     * Fooのidを指定して取得
     */
    @Results(
        id = "DbFoo", value = [
            Result(column = "id", property = "id", id = true),
            Result(column = "name", property = "name"),
            Result(column = "insert_timestamp", property = "insertTimestamp")
        ]
    )
    @SelectProvider(FooMapper.FooSqlProvider::class)
    fun getFoo(@Param("id") id: Int): DbFoo?

    /**
     * Fooをinsert_timestampの新しい順に取得
     */
    @ResultMap("DbFoo")
    @SelectProvider(FooMapper.FooSqlProvider::class)
    fun listFoo(@Param("offset") offset: Int?, @Param("limit") limit: Int?): List<DbFoo>

    /**
     * SQL定義
     */
    class FooSqlProvider : ProviderMethodResolver {
        companion object {
            private const val FOO = "foo"

            @JvmStatic
            fun selectFoo(): SQL {
                return SQL().SELECT("$FOO.id, $FOO.name, $FOO.insert_timestamp").FROM(FOO)
            }

            @JvmStatic
            fun getFoo(@SuppressWarnings("UNUSED_PARAMETER") id: Int): String =
                selectFoo().WHERE("$FOO.id = #{id}").toString()

            @JvmStatic
            fun listFoo(@Param("offset") offset: Int?, @Param("limit") limit: Int?): String {
                val baseQuery = selectFoo()
                offset?.let { baseQuery.OFFSET(it.toLong()) }
                limit?.let { baseQuery.LIMIT(it) }
                baseQuery.ORDER_BY("$FOO.insert_timestamp desc")
                return baseQuery.toString()
            }
        }
    }
}

data classにマッピングしたい

MyBatisは、DBから取得した結果を、指定したクラスのオブジェクトにマッピングしてくれるのですが、 data class にマッピングさせようとすると、デフォルトコンストラクタがなくてうまくいきません。

そこで、No-arg compiler plugin を導入して、指定したannotaionがあるクラスに対して 引数なしコンストラクタを準備します*3

// build.gradle

buildscript {
    ext {
        kotlinVersion = '1.3.61'
    }
    dependencies {
        classpath("org.jetbrains.kotlin:kotlin-noarg:${kotlinVersion}")
    }
}

noArg {
    annotation("com.m3.foobar.annotation.Data")
}

アノテーションの定義。

package com.m3.foobar.annotation

annotation class Data

DBからマッピングするdata classには @Data アノテーションをつけます。

package com.m3.foobar.adapter.gateway.db.model

import com.m3.foobar.annotation.Data
import java.time.OffsetDateTime

@Data
class DbFoo(
    /** id */
    val id: Int,
    /** name */
    val name: String,
    /** insert timestamp */
    val insertTimesamp: OffsetDateTime
)

Vueのビルド

フロント部分にはVue.jsを使っています。 Vueのビルドもgradleで実施するためにプラグインを入れました。

// build.gradle

buildscript {
    dependencies {
        classpath('com.moowork.gradle:gradle-node-plugin:1.3.1')
    }
}

apply plugin: 'com.moowork.node'

node {
    version = '12.4.0'
    yarnVersion = '1.16.0'
    download = true
}

// vue
yarn_install {
    workingDir = file("${project.projectDir}/src/main/vue")
}
task buildVue(type: YarnTask, dependsOn: 'yarn_install') {
    workingDir = file("${project.projectDir}/src/main/vue")
    args = ['run', 'build']
}
yarn_install.onlyIf { !project.hasProperty('skipBuildVue') }
buildVue.onlyIf { !project.hasProperty('skipBuildVue') }

processResources.dependsOn 'buildVue'

CIでKotlin側のテストをVue側のテストを並行して実行させる際に、 Kotlin側のテストで毎回Vueのビルドが実行されてしまうのが無駄だったので、 Vueのビルドをskipする設定を足しています。

gradle test -PskipBuildVue

静的解析

コンパイル時の警告もエラー扱いにする

コンパイル時警告であってもCIを止めたいので、 allWarningsAsErrors = true を設定しました。

compileKotlin {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
        allWarningsAsErrors = true
        jvmTarget = "11"
        javaParameters = true
    }
}

compileTestKotlin {
    kotlinOptions {
        freeCompilerArgs = ["-Xjsr305=strict"]
        allWarningsAsErrors = true
        jvmTarget = "11"
        javaParameters = true
    }
}

ktlint

フォーマットのチェックはktlintで行いました。 ほぼデフォルトの設定で、特に説明する点はありません。 一点だけ、IntelliJのimport順とルールが合わないので除外ルールを足しました。

// build.gradle

buildScript {
     dependencies {
        classpath("org.jlleitschuh.gradle:ktlint-gradle:9.1.0")
    }
}

apply plugin: 'org.jlleitschuh.gradle.ktlint'

configurations {
    ktlint
}

// ktlint
ktlint {
    // IntelliJのimport順とルールが合わないので除外
    disabledRules = ["import-ordering"]
}

detekt

detekt はフォーマットだけでなく、バグになりそうなコードを指摘してくれるツールです。

github.com

Javaにおける SpotBugs(旧FindBugs)PMD のようなものです。

// build.gradle
buildScript {
     dependencies {
        classpath('io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.1.1')
    }
}

apply plugin: 'io.gitlab.arturbosch.detekt'

// detekt
detekt {
    input = files("src/main/kotlin")
    config = files("ci/detekt.yml")
    // 設定ファイルはデフォルトの差分だけを記述する
    buildUponDefaultConfig = true
}
tasks.withType(io.gitlab.arturbosch.detekt.Detekt) {
     // Open API Generator 生成のファイルは除外
    exclude("**/com/m3/foobar/adapter/restapi/controller/*")
}

以前の記事で書いたように、 API用のコードはOpenAPI Generatorで生成しています。 これらのコードは指摘されても直すわけにもいかないのでチェック対象外にしました。

また、全体に適用、除外するルールなどは別の設定ファイル (ci/detekt.yml) で実施します。 今回のプロジェクトでは、デフォルトのルールに上書きして、1個でも警告、エラーが検出されたらビルド失敗としています。 ただし、 complexity は容認しています*4

# ci/detekt.yml

# see also: https://github.com/arturbosch/detekt/blob/master/detekt-cli/src/main/resources/default-detekt-config.yml

build:
  maxIssues: 1   # 1個でもあれば止める
  weights:
    complexity: 0 # complexity は容認

また、個別に除外する場合はannotationで指定します。

@Suppress("SpreadOperator")
fun main(args: Array<String>) {
    runApplication<Application>(*args)
}

detekt の各ルールにはそれぞれ debt いわゆる技術的負債が設定されています。 公式ページにそれっぽい説明はないですが、ルールに反するコードを直すのにかかる時間です。 例えば複雑すぎる関数 (ComplexMethod) は20分です*5

検出された指摘に合わせて負債を積み上げていくと、現状の負債状況が分かる仕組みです。

181 kotlin files were analyzed.
Ruleset: complexity - 3h 20min debt
   ComplexMethod - 12/10 - [xxxxxx] at Xxx.kt:37:5
       ...
       ...
Overall debt: 3h 20min

OWASP Dependency Check

すでに脆弱性が指摘されているバージョンのライブラリを使っていないかをチェックします。 Javaで使われているものが一般的ですが、Kotlinであっても多くはJavaライブラリを利用するので Javaと変わりなくチェックできます。

# build.gradle

buildscript {
    dependencies {
        classpath("org.owasp:dependency-check-gradle:5.2.2")
    }
}

apply plugin: 'org.owasp.dependencycheck'

// dependency check
dependencyCheck {
    cve {
        // 社内で立てたミラーを見る
        urlModified = 'https://xxxx/xxxx/nvdcve-1.0-modified.json.gz'
        urlBase = 'https://xxxx/xxxx/nvdcve-1.0-%d.json.gz'
    }
}

デフォルトでは、脆弱性のチェックの元となるデータはNISTのサーバから直接取得します。 このサーバがたまに不調で脆弱性情報が取れないことがあり、 その度にdependency-checkをしているプロジェクトのCIが全部失敗していました。 そこで、ミラーをS3に立てて社内からはこのミラーを参照するようにしています。 ミラーを作るためのプロジェクトが公開されているので、これを定期的に実行してS3に置いています。

github.com

テスト

JUnit5, Mockito

テストはJUnit5を使いました。弊社の去年のアドベントカレンダーでも紹介されていました。 詳しい特徴はこの記事を見てください。 @Nested@ParameterizedTest はぜひ活用すべきです。

www.m3tech.blog

なお、 @Nestedinner class である必要があります。

import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test


@DisplayName("FooRepositoryImplのテスト")
class FooRepositoryImplTest {

    @DisplayName("Fooを取得する")
    @Nested
    inner class GetFoo {
        @DisplayName("当該Fooがない場合")
        @Test
        fun notFound() {
            ...
        }

        @DisplayName("当該Fooがある場合")
        @Test
        fun found() {
            ...
        }
    }
}

@DisplayName でテストケースを説明した方が メソッド名を工夫するより分かりやすいです*6。 IntelliJのテストRunnerにも表示されます。

f:id:fukubaya:20191209171905p:plain
IntelliJで実行した例

MockitoはKotlin対応されている mockito-kotlin を使いました。 また、Javaの final class をmockする必要があったため、 mockito-core を使っています。

github.com

注意点としては、 spring-boot-starter-test にJunit4が入っているのでこれを除きます。 mockito も最新を使うために除きます。

buildscript {
    ext {
        kotlinVersion = '1.3.61'
        junitJupiterVersion = '5.5.2'
        junitPlatformEngineVersion = '1.5.2'
    }
}

dependencies {
    // assertj
    compile('org.assertj:assertj-core:3.14.0')


    // test
    testCompile("org.springframework.boot:spring-boot-starter-test:${springBootVersion}") {
        // junit5を使うのでjunit4を除く
        exclude module: 'junit'
        // mockitoは最新を使う
        exclude module: 'mockito-core'
    }
    testCompile("org.jetbrains.kotlin:kotlin-test:${kotlinVersion}")
    testCompile("org.jetbrains.kotlin:kotlin-test-junit5:${kotlinVersion}")
    testCompile("org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}")
    testCompile("org.junit.jupiter:junit-jupiter-params:${junitJupiterVersion}")
    testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${junitJupiterVersion}")
    testRuntimeOnly("org.junit.platform:junit-platform-engine:${junitPlatformEngineVersion}")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher:${junitPlatformEngineVersion}")
    testRuntimeOnly("org.junit.platform:junit-platform-commons:${junitPlatformEngineVersion}")

    testCompile("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
    testCompile("org.mockito:mockito-core:3.1.0")
}

test {
    useJUnitPlatform()
}

mockito-kotlin はKotlinで便利に使えるように作られていて、型推論でmockできておすすめです。

import com.nhaarman.mockitokotlin2.mock

class FooRepositoryImplTest {
    private lateinit var mockFooMapper: FooMapper
    private lateinit var target: FooRepositoryImpl

    @BeforeEach
    fun setUp() {
        mockFooMapper = mock() // 型を指定しなくてもよい
        target = FooRepositoryImpl(mockFooMapper)
    }
    ...
}

DBのテスト

DBの出入りの部分に関しては実際にDBを立ち上げて接続してテストしたかったので、 MyBatis-Spring-Boot-Starter-Testを利用します。

mybatis.org

buildscript {
    ext {
        myBatisSpringBootVersion = '2.1.1'
    }
}

dependencies {
    testCompile("org.mybatis.spring.boot:mybatis-spring-boot-starter-test:${myBatisSpringBootVersion}")
}

@MybatisTest をつけるとDB接続に必要なcomponentが読み込まれた状態でテストを実行できます。 ただし、デフォルトではIn-memory DBを使うので、 PostgreSQLなど実際のDBを使うには、 @AutoConfigureTestDatabase も必要です。 毎回これらのアノテーションをつけるのは手間と漏れの可能性があるので、自分でannotationを定義しました。

package com.m3.foobar.annotation

import org.mybatis.spring.boot.test.autoconfigure.MybatisTest
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase

/**
 * MyBatisで実際にDBに接続して実行するテスト。DB接続に必要な設定を読み込んで実行する。
 */
@MybatisTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
annotation class MapperTest
import com.m3.foobar.annotation.MapperTest

@MapperTest
class FooMapperTest {
...
}

カバレッジ

Javaプロジェクトでよく使われるJaCoCoはkotlinにも対応しているので、そのまま使います。

// build.gradle

buildScript {
    ext {
        jacocoVersion = '0.8.5'
    }
}

apply plugin: 'jacoco'

// jacoco configuration
jacoco {
    toolVersion = "${jacocoVersion}"
    reportsDir = file("$buildDir/customJacocoReportDir")
}

// jacocoTestReport configuration
jacocoTestReport {
    reports {
        xml.enabled true
        csv.enabled false
        html.enabled true
    }
    classDirectories = fileTree(
        dir: 'build/classes/kotlin/main',
        excludes: [
            // 設定用のclassは対象から外す
            'com/m3/foobar/config/**',
            // OpenApi Generatorで生成するコードは対象から外す
            'com/m3/foobar/adapter/restapi/controller/*.class',
            'com/m3/foobar/adapter/restapi/model/*.class',
        ]
    )
}

We are hiring!

「医師版Stack Overflow」(仮名)は、近日中にリリース予定ですが、 まだまだこれからも機能を追加していく予定です。 一緒に開発に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。

open.talentio.com

jobs.m3.com

*1:ビルドスクリプトはまだGroovyです…。

*2:例えば結合マッピングでネストされたオブジェクトへのマッピングができない

*3:このコンストラクタは、通常のJavaやKotlinのコードからは直接は呼べません

*4:あまりよくない。本当は守りたい

*5:https://arturbosch.github.io/detekt/complexity.html#complexmethod

*6:Testクラス自身はclass名自体で何のテストか分かるのでなくてもいいと思います