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
その他
- この投稿は、次のサイトを参考にして作成しました。
- 上記のすべてのソースコードはGitHubで確認できます。