KotlinでSpring Data Elasticsearchを作る

概要

Kotlin言語とSpring Data Elasticsearchを利用して、Elasticsearchクライアントを作成します。

このプロジェクトは、基本的にElasticsearchがインストールされていないと実行できません。インストールされていない場合は、先にElastic Searchのインストールを済ませてください。

プロジェクト作成

次のようにcurlコマンドを使用して、Spring Bootの初期プロジェクトを作成します。

curl https://start.spring.io/starter.tgz \
-d bootVersion=2.5.5 \
-d dependencies=web,data-elasticsearch \
-d baseDir=spring-data-elasticsearch \
-d groupId=com.devkuma \
-d artifactId=spring-data-elasticsearch \
-d packageName=com.devkuma.elasticsearch \
-d applicationName=ElasticsearchApplication \
-d packaging=jar \
-d language=kotlin \
-d javaVersion=11 \
-d type=gradle-project | tar -xzvf -

生成されたプロジェクトの確認

プロジェクトファイル構造

正しく生成されていれば、プロジェクトのファイル構造は次のようになります。

.
├── HELP.md
├── build.gradle.kts
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   │   └── com
    │   │       └── devkuma
    │   │           └── elasticsearch
    │   │               └── ElasticsearchApplication.kt
    │   └── resources
    │       └── application.properties
    └── test
        └── kotlin
            └── com
                └── devkuma
                    └── elasticsearch
                        └── ElasticsearchApplicationTests.kt

ビルドスクリプト

/build.gradle.kts

// ... 省略 ...

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

// ... 省略 ...

依存ライブラリにSpring Data Elasticsearchライブラリ(spring-boot-starter-data-elasticsearch)が含まれていることがわかります。

Elasticsearch設定

Applicationに関連する設定を追加

/src/main/kotlin/com/devkuma/elasticsearch/classElasticsearchApplication.kt

package com.devkuma.elasticsearch

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication

@SpringBootApplication
@ConfigurationPropertiesScan
class ElasticsearchApplication

fun main(args: Array<String>) {
    runApplication<ElasticsearchApplication>(*args)
}

設定プロパティをファイルから読み込むため、クラスに@ConfigurationPropertiesScanを追加します。

アプリケーション設定を追加

アプリケーション設定ファイル(application.yml)にElasticsearch関連設定を追加します。
初期プロジェクトを生成すると、application.propertiesファイルがあるはずです。このファイルの拡張子だけを.ymlに変更すればよいです。
/src/main/resources/application.yml

elasticsearch:
  host: localhost
  port: 9200

logging:
  level:
    org.springframework: INFO
    org.devkuma: DEBUG

設定名からすぐわかるように、elasticsearchはElasticsearch設定で、loggingはログ関連設定です。

プロパティ設定オブジェクト作成

/src/main/kotlin/com/devkuma/elasticsearch/config/ElasticsearchProperties.kt

package com.devkuma.elasticsearch.config

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding

@ConstructorBinding
@ConfigurationProperties(prefix = "elasticsearch")
class ElasticsearchProperties(
    private val host: String,
    private val port: Integer
) {
    fun getHostAndPort(): String {
        return "$host:$port"
    }
}

プロパティに加えてgetHostAndPort関数を作成し、ホストとポートを一度に取得できるようにしました。

Elasticsearch設定ソースファイル作成

/src/main/kotlin/com/devkuma/elasticsearch/config/ElasticsearchConfig.kt

package com.devkuma.elasticsearch.config

import org.elasticsearch.client.RestHighLevelClient
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.elasticsearch.client.ClientConfiguration
import org.springframework.data.elasticsearch.client.RestClients
import org.springframework.data.elasticsearch.config.AbstractElasticsearchConfiguration
import org.springframework.data.elasticsearch.repository.config.EnableElasticsearchRepositories


@Configuration
@EnableElasticsearchRepositories
class ElasticsearchConfig(
    var elasticsearchProperties: ElasticsearchProperties
) : AbstractElasticsearchConfiguration() {

    override fun elasticsearchClient(): RestHighLevelClient {
        val clientConfiguration: ClientConfiguration = ClientConfiguration.builder()
            .connectedTo(elasticsearchProperties.getHostAndPort())
            .build();

        return RestClients.create(clientConfiguration).rest();
    }
}

ソースを見ると、AbstractElasticsearchConfigurationを継承しており、上で設定したElasticsearchPropertiesを受け取っています。

Elasticsearch Repository作成

Elasticsearchへ直接接続するリポジトリを作ってみましょう。

ドキュメントオブジェクト作成

まず、データを受け取るドキュメントオブジェクトを作成します。

/src/main/kotlin/com/devkuma/elasticsearch/document/Phone.kt

package com.devkuma.elasticsearch.document

import org.springframework.data.annotation.Id
import org.springframework.data.elasticsearch.annotations.Document

@Document(indexName = "phone")
class Phone(
    @Id
    var id: Int,
    val number: String,
    val author: String
)

ここでは例として電話番号情報を作成しました。@Documentを設定し、インデックス名を設定しています。

Repositoryオブジェクト作成

リポジトリオブジェクトを作成します。

/src/main/kotlin/com/devkuma/elasticsearch/repository/ElasticsearchRepository.kt

package com.devkuma.elasticsearch.repository

import com.devkuma.elasticsearch.document.Phone
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository

interface ElasticsearchRepository: ElasticsearchRepository<Phone, Int>

ソースファイルを見ると、ElasticsearchRepositoryを継承していることが確認できます。

RepositoryのTestコード作成

リポジトリオブジェクトをテストできるコードを作成します。

/src/test/kotlin/com/devkuma/elasticsearch/repository/ElasticsearchRepositoryTests.kt

package com.devkuma.elasticsearch.repository

import com.devkuma.elasticsearch.document.Phone
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest


@SpringBootTest
class ElasticsearchRepositoryTests() {

    @Autowired
    private lateinit var elasticsearchRepository: ElasticsearchRepository

    @Test
    @DisplayName("저장")
    fun save() {
        val phone: Phone = Phone(1, "010-0000-0000", "devkuma")
        elasticsearchRepository.save(phone)
    }

    @Test
    @DisplayName("조회")
    fun findById() {
        // 저장
        val savedPhone: Phone = Phone(1, "010-0000-0000", "devkuma")
        elasticsearchRepository.save(savedPhone)

        // 조회
        val searchedPhone = elasticsearchRepository.findById(1).orElseGet(null)

        // 테스트
        assertNotNull(searchedPhone)
        assertEquals(savedPhone.id, searchedPhone.id)
        assertEquals(savedPhone.number, searchedPhone.number)
        assertEquals(savedPhone.author, searchedPhone.author)
    }
}

実行して、すべて失敗なく通過するか確認しましょう。

Elasticsearch Service作成

今回は、リポジトリから読み取れるようにサービスを作成します。

Serviceオブジェクト作成

サービスオブジェクトを作成します。

/src/main/kotlin/com/devkuma/elasticsearch/service/ElasticsearchService.kt

package com.devkuma.elasticsearch.service

import com.devkuma.elasticsearch.document.Phone
import com.devkuma.elasticsearch.repository.ElasticsearchRepository
import org.springframework.stereotype.Service

@Service
class ElasticsearchService(
    private val elasticsearchRepository: ElasticsearchRepository
) {

    fun save(phone: Phone): Phone {
        return elasticsearchRepository.save(phone)
    }

    fun findById(id: Int): Phone {
        return elasticsearchRepository.findById(id).orElseThrow()
    }

    fun delete(id: Int) {
        val phone =  elasticsearchRepository.findById(id).orElseThrow()
        elasticsearchRepository.delete(phone)
    }
}

ServiceのTestコード作成

サービスオブジェクトをテストできるコードを作成します。

/src/test/kotlin/com/devkuma/elasticsearch/service/ElasticsearchServiceTests.kt

package com.devkuma.elasticsearch.service

import com.devkuma.elasticsearch.document.Phone
import com.devkuma.elasticsearch.repository.ElasticsearchRepository
import org.junit.jupiter.api.*
import org.mockito.ArgumentMatchers.any
import org.mockito.BDDMockito.given
import org.mockito.InjectMocks
import org.mockito.Mock
import org.springframework.boot.test.context.SpringBootTest
import java.util.*


@SpringBootTest
class ElasticsearchServiceTests {

    @Mock
    private lateinit var elasticsearchRepository: ElasticsearchRepository
    @InjectMocks
    private lateinit var elasticsearchService: ElasticsearchService

    @BeforeEach
    fun setUp() {
        elasticsearchService = ElasticsearchService(elasticsearchRepository)
    }

    @Test
    fun save() {
        // given
        val phone = Phone(1, "010-0000-0000", "devkuma")
        given(elasticsearchRepository.save(any())).willReturn(phone)

        // when
        val savedPhone = elasticsearchService.save(phone)

        // then
        Assertions.assertAll(
            { Assertions.assertNotNull(savedPhone) },
            { Assertions.assertEquals(savedPhone.id, phone.id) },
            { Assertions.assertEquals(savedPhone.number, phone.number) },
            { Assertions.assertEquals(savedPhone.author, phone.author) }
        )
    }

    @Test
    fun findById() {
        // given
        val phone = Phone(1, "010-0000-0000", "devkuma")
        given(elasticsearchRepository.findById(any())).willReturn(Optional.of(phone))

        // 조회
        val searchedPhone = elasticsearchService.findById(1)

        // then
        Assertions.assertAll(
            { Assertions.assertNotNull(searchedPhone) },
            { Assertions.assertEquals(phone.id, searchedPhone.id) },
            { Assertions.assertEquals(phone.number, searchedPhone.number) },
            { Assertions.assertEquals(phone.author, searchedPhone.author) }
        )
    }

    @Test
    fun delete() {
        // given
        val phone = Phone(1, "010-0000-0000", "devkuma")
        given(elasticsearchRepository.findById(any())).willReturn(Optional.of(phone))

        // given
        assertDoesNotThrow { elasticsearchService.delete(1) }
    }
}

実行して、すべて失敗なく通過するか確認しましょう。

Elasticsearch Controller作成

最後に、サービスを呼び出すコントローラーを作成します。

Controllerファイルを作成する

/src/main/kotlin/com/devkuma/elasticsearch/controller/ElasticsearchController.kt

package com.devkuma.elasticsearch.controller

import com.devkuma.elasticsearch.document.Phone
import com.devkuma.elasticsearch.service.ElasticsearchService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.net.URI


@RestController
@RequestMapping("/phones")
class ElasticsearchController(
    private val elasticsearchService: ElasticsearchService
) {

    @GetMapping("/{id}")
    fun findById(@PathVariable id: Int): ResponseEntity<Phone> {
        return ResponseEntity.ok().body(elasticsearchService.findById(id))
    }

    @PostMapping
    fun add(@RequestBody phone: Phone): ResponseEntity<Phone> {
        elasticsearchService.add(phone);
        return ResponseEntity.created(URI.create("/phones/${phone.id}")).build()
    }

    @DeleteMapping("/{id}")
    fun delete(@PathVariable id: Int): ResponseEntity<Object> {
        elasticsearchService.delete(id)
        return ResponseEntity.noContent().build()
    }
}

ControllerのTestコードを作成する

/src/test/kotlin/com/devkuma/elasticsearch/controller/ElasticsearchControllerTests.kt

package com.devkuma.elasticsearch.controller

import com.devkuma.elasticsearch.document.Phone
import com.devkuma.elasticsearch.service.ElasticsearchService
import com.fasterxml.jackson.databind.ObjectMapper
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.*
import org.mockito.BDDMockito.given
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType.APPLICATION_JSON
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*

@WebMvcTest(ElasticsearchController::class)
class ElasticsearchControllerTests {

    @Autowired
    private lateinit var mockMvc: MockMvc
    @MockBean
    private lateinit var elasticsearchService: ElasticsearchService

    val phone = Phone(1, "010-0000-0000", "devkuma")

    @Test
    fun findById() {
        // given
        given(elasticsearchService.findById(anyInt())).willReturn(phone)

        // when
        var resultActions = mockMvc.perform(get("/phones/{id}", phone.id))
            .andDo(print())

        // then
        resultActions
            .andExpect(status().is2xxSuccessful)
            .andExpect(jsonPath("$.id").value(phone.id))
            .andExpect(jsonPath("$.number").value(phone.number))
            .andExpect(jsonPath("$.author").value(phone.author))
            .andDo(print())
    }

    @Test
    fun add() {
        // given ma")
        given(elasticsearchService.add(phone)).willReturn(phone)

        // when
        var resultActions = mockMvc.perform(post("/phones")
            .contentType(APPLICATION_JSON)
            .content(ObjectMapper().writeValueAsString(phone)))
            .andDo(print())

        // then
        resultActions
            .andExpect(status().is2xxSuccessful)
            .andExpect(redirectedUrlTemplate("/phones/{id}", phone.id))
            .andDo(print())
    }

    @Test
    fun delete() {
        // when
        var resultActions = mockMvc.perform(delete("/phones/{id}", phone.id))
            .andDo(print())

        // then
        resultActions
            .andExpect(status().is2xxSuccessful)
            .andDo(print())
    }
}

ここでも実行して、すべて失敗なく通過するか確認しましょう。

curlコマンドで実際にテストしてみる

プロジェクトを作成し、実際に動作するかcurlコマンドでテストしてみましょう。

追加

% curl -X POST http://localhost:8080/phones \
   -H "Content-Type: application/json" \
   -d '{"id":1,"number":"010-0000-0000","author":"devkuma"}'

削除

% curl -XGET http://localhost:8080/phones/1
{"id":1,"number":"010-0000-0000","author":"devkuma"}%

照会

% curl -XDELETE http://localhost:8080/phones/1

その他