概要
Kotlinを学習するためにKotlinとSpring Bootで簡単なRest APIアプリケーションを実装してみました。(Kotlinの基礎的な言語仕様については良い情報がたくさんあるのでこの記事では扱いません)
なお、Spring Frameworkはバージョン5よりCore APIレベルでKotlinをサポートしていますが(詳しくは[Spring Framework 5のKotlinサポート] (https://blog.ik.am/entries/407)をご覧ください)、この記事ではSpring Boot 1.5を使用しますのでAPIレベルでKotlinの利用はできていません。
ソースコードは[rubytomato/demo-kotlin-spring] (https://github.com/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
参考
- [Reference - Kotlin Programming Language] (https://kotlinlang.org/docs/reference/)
- [Developing Spring Boot applications with Kotlin] (https://spring.io/blog/2016/02/15/developing-spring-boot-applications-with-kotlin)
アプリケーション
[SPRING INITIALIZR] (https://start.spring.io/)を使ってアプリケーションのひな型を生成しました。
実装する内容は、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は最終的に下記の内容になりました。
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] (https://kotlinlang.org/docs/reference/object-declarations.html#companion-objects)で代替することもできます)
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ではどのようなコードになるか確認したかったのであえて実装しました。
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] (https://kotlinlang.org/docs/reference/data-classes.html)というものがありますので、エンティティの実装に使用しました。
(エンティティクラスをdata classにしなければならないということではありません)
JPAの仕様ではエンティティクラスにはpublicで引数を取らないコンストラクタが必要です。このコンストラクタを暗黙的に定義してくれるKotlinの[No-arg compiler plugin] (https://kotlinlang.org/docs/reference/compiler-plugins.html#no-arg-compiler-plugin)を使用しています。
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とほとんど変わりはありません。
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が代入可能となります。
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)
}
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"]
のように記述できます。
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] (https://spring.io/blog/2016/03/04/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"
{
"title": "new memo title",
"description": "new memo description",
"done": false
}
削除
> curl -v -X DELETE "http://localhost:9000/app/memo/1"
テストコード
リポジトリの単体テスト
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の予約語なので、バッククォートでエスケープする必要があります。
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)
}
}
コントローラーの結合テスト
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] (https://spring.io/blog/2017/01/04/introducing-kotlin-support-in-spring-framework-5-0)
[Language Support Version 5.0.2.RELEASE] (https://docs.spring.io/spring/docs/current/spring-framework-reference/languages.html)
[Spring Framework 5のKotlinサポート] (https://blog.ik.am/entries/407)
Kotlin Plugins
[Compiler Plugins] (https://kotlinlang.org/docs/reference/compiler-plugins.html)
- All-open compiler plugin
- No-arg compiler plugin