Kotlin
spring-boot

Kotlin with Spring Boot 1.5で簡単なRest APIを実装する

概要

Kotlinを学習するためにKotlinとSpring Bootで簡単なRest APIアプリケーションを実装してみました。(Kotlinの基礎的な言語仕様については良い情報がたくさんあるのでこの記事では扱いません)
なお、Spring Frameworkはバージョン5よりCore APIレベルでKotlinをサポートしていますが(詳しくはSpring Framework 5のKotlinサポートをご覧ください)、この記事ではSpring Boot 1.5を使用しますのでAPIレベルでKotlinの利用はできていません。

ソースコードはrubytomato/demo-kotlin-springにあります。

環境

  • Windows 10 Professional
  • Java 1.8.0_172
  • Kotlin 1.2.31
  • Spring Boot 1.5.13
    • Gradle
    • MySQL 5.7.19

参考

アプリケーション

SPRING INITIALIZRを使ってアプリケーションのひな型を生成しました。

実装する内容は、MemoテーブルのデータをIDを指定して検索や削除をしたり、PostされたJsonデータをテーブルに更新するAPIになります。

使用するテーブルとテストデータ

DROP TABLE IF EXISTS memo;

CREATE TABLE IF NOT EXISTS memo (
  id BIGINT AUTO_INCREMENT,
  title VARCHAR(255) NOT NULL,
  description TEXT NOT NULL,
  done BOOLEAN NOT NULL DEFAULT FALSE,
  updated TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id)
)
ENGINE = INNODB,
CHARACTER SET = utf8mb4,
COLLATE utf8mb4_general_ci;

テストデータ

INSERT INTO memo (id, title, description, done, updated) VALUES
  (1, 'memo shopping', 'memo1 description', false, '2018-01-04 12:01:00'),
  (2, 'memo job', 'memo2 description', false, '2018-01-04 13:02:10'),
  (3, 'memo private', 'memo3 description', false, '2018-01-04 14:03:21'),
  (4, 'memo job', 'memo4 description', false, '2018-01-04 15:04:32'),
  (5, 'memo private', 'memo5 description', false, '2018-01-04 16:05:43'),
  (6, 'memo travel', 'memo6 description', false, '2018-01-04 17:06:54'),
  (7, 'memo travel', 'memo7 description', false, '2018-01-04 18:07:05'),
  (8, 'memo shopping', 'memo8 description', false, '2018-01-04 19:08:16'),
  (9, 'memo private', 'memo9 description', false, '2018-01-04 20:09:27'),
  (10,'memo hospital', 'memoA description', false, '2018-01-04 21:10:38')
;

build.gradle

build.gradleは最終的に下記の内容になりました。

build.gradle
buildscript {
    ext {
        kotlinVersion = "1.2.31"
        springBootVersion = "1.5.13.RELEASE"
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
        classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${kotlinVersion}")
        classpath("org.jetbrains.kotlin:kotlin-allopen:${kotlinVersion}")
        classpath("org.jetbrains.kotlin:kotlin-noarg:${kotlinVersion}")
    }
}

apply plugin: "kotlin"
apply plugin: "kotlin-spring"
apply plugin: "kotlin-jpa"
apply plugin: "org.springframework.boot"
apply plugin: "kotlin-kapt"

group = "com.example"
version = "0.0.3-SNAPSHOT"
sourceCompatibility = 1.8
compileKotlin {
    kotlinOptions.jvmTarget = "1.8"
}
compileTestKotlin {
    kotlinOptions.jvmTarget = "1.8"
}
compileJava {
    options.compilerArgs << "-Xlint:unchecked,deprecation"
}
compileTestJava {
    options.compilerArgs << "-Xlint:unchecked,deprecation"
}

repositories {
    mavenCentral()
}

dependencies {
    // for web
    compile("org.springframework.boot:spring-boot-starter-web")
    // Jackson
    compile("com.fasterxml.jackson.core:jackson-databind")
    compile("com.fasterxml.jackson.datatype:jackson-datatype-jdk8")
    compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
    compile("com.fasterxml.jackson.module:jackson-module-kotlin")
    // for db
    compile("org.springframework.boot:spring-boot-starter-data-jpa")
    compile("org.hibernate:hibernate-java8")
    runtime("mysql:mysql-connector-java")
    // kotlin
    compile("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    compile("org.jetbrains.kotlin:kotlin-reflect")
    // for dev
    kapt("org.springframework.boot:spring-boot-configuration-processor")
    compileOnly("org.springframework.boot:spring-boot-configuration-processor")
    runtime("org.springframework.boot:spring-boot-devtools")
    // for test
    testCompile("org.springframework.boot:spring-boot-starter-test")
    testCompile("com.h2database:h2")
}

アプリケーションの起動クラス

起動クラスは生成されたひな型のコードのままで変更点はありません。
初めて見るとmainメソッドがクラスの外側(パッケージのトップレベル)に定義されていることに違和感を感じますが、Kotlinはstaticメソッドが定義できないので、この例のようにパッケージのトップレベルに関数として記述するようです。(Companion Objectsで代替することもできます)

DemoKotlinApplication.kt
package com.example.demokotlin

import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication

@SpringBootApplication
class DemoKotlinApplication

fun main(args: Array<String>) {
    SpringApplication.run(DemoKotlinApplication::class.java, *args)
}

ロギング

Javaの場合はLombokのSlf4jアノテーションを使っていたのですが、KotlinはLombokを併用することが難しいようで、このサンプルでは下記のようにログが必要なクラス毎にメンバを定義しました。

private val log = LoggerFactory.getLogger(MemoController::class.java)

データソース

わざわざ下記のようなクラスを実装しなくてもデータベース接続やトランザクション管理は行えますが、Kotlinではどのようなコードになるか確認したかったのであえて実装しました。

DataSourceConfigure.kt
package com.example.demokotlin.datasource

import org.springframework.boot.autoconfigure.jdbc.DataSourceBuilder
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.orm.jpa.JpaTransactionManager
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.EnableTransactionManagement
import javax.persistence.EntityManagerFactory
import javax.sql.DataSource

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
        basePackages = ["com.example.demokotlin.repository"],
        entityManagerFactoryRef = "entityManagerFactory",
        transactionManagerRef = "transactionManager"
)
class DataSourceConfigure {

    @ConfigurationProperties(prefix = "spring.datasource")
    @Bean
    fun datasource(): DataSource =
        DataSourceBuilder.create().build()

    @Bean("entityManagerFactory")
    fun entityManagerFactory(_builder: EntityManagerFactoryBuilder): LocalContainerEntityManagerFactoryBean {
        return _builder.dataSource(datasource())
                .persistenceUnit("default-PU")
                .packages("com.example.demokotlin.entity")
                .build()
    }

    @Bean("transactionManager")
    fun transactionManager(_entityManagerFactory: EntityManagerFactory): PlatformTransactionManager {
        return JpaTransactionManager().apply {
            entityManagerFactory = _entityManagerFactory
        }
    }

}

Kotlinでは関数が単一式の場合はreturnを省略できます。下記は省略しなかった場合のコードです。

fun datasource(): DataSource {
    return DataSourceBuilder.create().build()
}

エンティティとリポジトリ

Kotlinには、データを保持する目的のクラスのためにData classというものがありますので、エンティティの実装に使用しました。
(エンティティクラスをdata classにしなければならないということではありません)
JPAの仕様ではエンティティクラスにはpublicで引数を取らないコンストラクタが必要です。このコンストラクタを暗黙的に定義してくれるKotlinのNo-arg compiler pluginを使用しています。

Memo.kt
package com.example.demokotlin.entity

import java.io.Serializable
import java.time.LocalDateTime
import javax.persistence.*

@Entity
@Table(name="memo")
data class Memo (
    @Column(name="title", nullable = false)
    var title: String,
    @Column(name="description", nullable = false)
    var description: String,
    @Column(name="done", nullable = false)
    var done: Boolean = false,
    @Column(name="updated", nullable = false)
    var updated: LocalDateTime = LocalDateTime.now(),
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0
): Serializable

クラス名の後の( ... )の部分はプライマリコンストラクタといいます。このコンストラクタに可視性やアノテーションが必要な場合は下記のように記述します。

data class Memo public @Inject constructor(

  //省略

): Serializable

リポジトリはJavaとほとんど変わりはありません。

MemoRepository.kt
package com.example.demokotlin.repository

import com.example.demokotlin.entity.Memo
import org.springframework.data.jpa.repository.JpaRepository

interface MemoRepository: JpaRepository<Memo, Long>

サービス

サービスクラスもほとんどJavaと同じになっていますが、KotlinらしいところとしてfindByIdの戻り値の型をMemo?と定義しnullの可能性があることを明示しています。
Kotlinでは型に?を付けるとnullが代入可能となります。

MemoService.kt
package com.example.demokotlin.service

import com.example.demokotlin.entity.Memo
import org.springframework.data.domain.Pageable

interface MemoService {
    fun findById(id: Long): Memo?
    fun findAll(page: Pageable): List<Memo>
    fun store(memo: Memo)
    fun remove(id: Long)
}
MemoServiceImpl.kt
package com.example.demokotlin.service.impl

import com.example.demokotlin.entity.Memo
import com.example.demokotlin.repository.MemoRepository
import com.example.demokotlin.service.MemoService
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class MemoServiceImpl(
    private val repository: MemoRepository): MemoService {

    @Transactional(readOnly = true)
    override fun findById(id: Long): Memo? =
        repository.findOne(id)

    @Transactional(readOnly = true)
    override fun findAll(page: Pageable): List<Memo> =
        repository.findAll(page).content

    @Transactional(timeout = 10)
    override fun store(memo: Memo) {
        repository.save(memo)
    }

    @Transactional(timeout = 10)
    override fun remove(id: Long) {
        repository.delete(id)
    }

}

コントローラー

コントローラークラスもJavaとだいたい同じような感じです。
RequestMappingやGetMappingアノテーションのpathはArray<String>型で指定する必要があり、KotlinではarrayOf("memo")または["memo"]のように記述できます。

MemoController.kt
package com.example.demokotlin.controller

import com.example.demokotlin.entity.Memo
import com.example.demokotlin.service.MemoService
import org.springframework.data.domain.Pageable
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping(path = ["memo"])
class MemoController(
    private val service: MemoService) {

    private val log = LoggerFactory.getLogger(MemoController::class.java)

    @GetMapping(path = ["{id}"], produces = [MediaType.APPLICATION_JSON_UTF8_VALUE])
    fun id(@PathVariable(value = "id") id: Long): ResponseEntity<Memo> {
        val memo = service.findById(id)
        memo?.let { return ResponseEntity.ok(memo) }
           ?: run { return ResponseEntity(HttpStatus.NOT_FOUND) }
        /*
        return if (memo != null) {
            ResponseEntity.ok(memo)
        } else {
            ResponseEntity(HttpStatus.NOT_FOUND)
        }
        */
    }

    @GetMapping(path = ["list"], produces = [MediaType.APPLICATION_JSON_UTF8_VALUE])
    fun list(page: Pageable): ResponseEntity<List<Memo>> {
        return ResponseEntity.ok(service.findAll(page))
    }

    @PostMapping(produces = [MediaType.TEXT_PLAIN_VALUE], consumes = [MediaType.APPLICATION_JSON_UTF8_VALUE])
    fun store(@RequestBody memo: Memo): ResponseEntity<String> {
        service.store(memo)
        return ResponseEntity.ok("success")
    }

    @DeleteMapping(path = ["{id}"], produces = [MediaType.TEXT_PLAIN_VALUE])
    fun remove(@PathVariable(value = "id") id: Long): ResponseEntity<String> {
        log.debug("delete memo id:{}", id)
        service.remove(id)
        return ResponseEntity.ok("success")
    }

}

Kotlinとは関係ありませんが、Spring Framework 4.3以降はコンストラクタでのインジェクションでAutowiredアノテーションが省略できます。(ただし引数を取るコンストラクタが1つだけの場合)
下記のプライマリコンストラクタで行っているインジェクションのAutowiredは不要です。

class MemoController(
    //↓不要
    @Autowired private val service: MemoService) {

}

Core container refinements in Spring Framework 4.3

So as of 4.3, you no longer need to specify an explicit injection annotation in such a single-constructor scenario.

APIの動作確認

検索

> curl -v "http://localhost:9000/app/memo/1"
> curl -v "http://localhost:9000/app/memo/list"
> curl -v "http://localhost:9000/app/memo/list?page=0&size=5"

新規登録

> curl -v -H "Content-Type:application/json" -d @memo.json -X POST "http://localhost:9000/app/memo"
memo.json
{
    "title": "new memo title",
    "description": "new memo description",
    "done": false
}

削除

> curl -v -X DELETE "http://localhost:9000/app/memo/1"

テストコード

リポジトリの単体テスト

MemoRepositoryTests.kt
package com.example.demokotlin.repository

import com.example.demokotlin.entity.Memo
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.context.junit4.SpringRunner
import java.time.LocalDateTime

@RunWith(SpringRunner::class)
@DataJpaTest
class MemoRepositoryTests {

    @Autowired
    lateinit var entityManager: TestEntityManager

    @Autowired
    lateinit var sut: MemoRepository

    @Test
    @Sql(statements = ["INSERT INTO memo (id, title, description, done, updated) VALUES (1, 'memo test', 'memo description', FALSE, CURRENT_TIMESTAMP)"])
    fun findOne() {
        val expected = entityManager.find(Memo::class.java, 1L)
        val actual = sut.findOne(expected.id)

        assertThat(actual).isEqualTo(expected)
    }

    @Test
    fun save() {
        val updated = LocalDateTime.of(2018, 1, 4, 0, 0, 0)
        val expected = Memo(title = "test title", description = "test description", done = true, updated = updated)
        sut.saveAndFlush(expected)
        val actual = entityManager.find(Memo::class.java, expected.id)

        assertThat(actual).isEqualTo(expected)
    }

}

サービスの単体テスト

MockitoのwhenがKotlinの予約語なので、バッククォートでエスケープする必要があります。

MemoServiceImplTests.kt
package com.example.demokotlin.service.impl

import com.example.demokotlin.entity.Memo
import com.example.demokotlin.repository.MemoRepository
import org.junit.Before
import org.junit.Test
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.MockitoAnnotations
import org.assertj.core.api.Assertions.assertThat

class MemoServiceImplTests {

    @InjectMocks
    lateinit var sut: MemoServiceImpl

    @Mock
    lateinit var repository: MemoRepository

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)
    }

    @Test
    fun findById() {
        val expected = Memo(title = "title", description = "description", done = true)
        Mockito.`when`(repository.findOne(Mockito.anyLong())).thenReturn(expected)
        val actual = sut.findById(expected.id)

        assertThat(actual).isEqualTo(expected)
    }

}

コントローラーの結合テスト

MemoControllerTests
package com.example.demokotlin.controller

import com.example.demokotlin.DemoKotlinApplication
import com.example.demokotlin.entity.Memo
import org.assertj.core.api.Assertions.*
import org.junit.Test
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.test.context.junit4.SpringRunner

class MemoList : MutableList<Memo> by ArrayList()

@RunWith(SpringRunner::class)
@SpringBootTest(classes = [DemoKotlinApplication::class],
        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MemoControllerTests {

    @Autowired
    lateinit var testRestTemplate: TestRestTemplate

    @Test
    fun `test get`() {
        val result = testRestTemplate.getForEntity("/memo/1", Memo::class.java)

        assertThat(result).isNotNull()
        assertThat(result.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(result.headers.contentType).isEqualTo(MediaType.APPLICATION_JSON_UTF8)
        assertThat(result.body.id).isEqualTo(1L)
    }

    @Test
    fun `test list`() {
        val result = testRestTemplate.getForEntity("/memo/list?page={page}&size={size}", MemoList::class.java, 0, 3)

        assertThat(result).isNotNull()
        assertThat(result.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(result.headers.contentType).isEqualTo(MediaType.APPLICATION_JSON_UTF8)
        assertThat(result.body).hasSize(3)
    }

}

備考

Spring Frameworkのkotlinサポートについて

Introducing Kotlin support in Spring Framework 5.0
Language Support Version 5.0.2.RELEASE
Spring Framework 5のKotlinサポート

Kotlin Plugins

Compiler Plugins

  • All-open compiler plugin
  • No-arg compiler plugin