こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。 この記事は エムスリー Advent Calendar 2019 の11日目の記事です。 昨日は大垣の 行動ログデータからのユーザーアンケート予測モデルを作り、ユーザーの嗜好分類をする でした。
今回も中村の記事で宣言した
「医師版Stack Overflow」(仮名)の技術的チャレンジのうち、
Kotlin + SpringBoot でのアプリケーション構成例を build.gradle
に沿ってご紹介します*1。
DBフレームワーク
MyBatis3
DBの入出力には、Kotlinで書かれているJetBrains製のExposedを検討していましたが、 細かい制御がまだ難しそうというアドバイスもあり、 MyBatis3を使うことにしました。
// 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 はフォーマットだけでなく、バグになりそうなコードを指摘してくれるツールです。
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に置いています。
テスト
JUnit5, Mockito
テストはJUnit5を使いました。弊社の去年のアドベントカレンダーでも紹介されていました。
詳しい特徴はこの記事を見てください。 @Nested
や @ParameterizedTest
はぜひ活用すべきです。
なお、 @Nested
は inner 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にも表示されます。
MockitoはKotlin対応されている mockito-kotlin
を使いました。
また、Javaの final class
をmockする必要があったため、 mockito-core
を使っています。
注意点としては、
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を利用します。
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」(仮名)は、近日中にリリース予定ですが、 まだまだこれからも機能を追加していく予定です。 一緒に開発に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。