Со времен релиза Kotlin прошло уже более года, да и Spring boot претерпел изменения. Наткнувшись на статью о том как написать простой RESTful сервис используя Kotlin и Spring boot, захотелось написать о том как же это можно сделать сегодня.
Эта небольшая статья ориентированна на тех кто никогда не писал код на Kotlin и не использовал Spring boot.
Подготовка проекта
Нам понадобится:
- сервер БД MySql
- IDE IntelliJ IDEA
- руки
Для начала идем на сайт Spring Initializr для формирования шаблона приложения. Заполняем форму и скачиваем полученную заготовку:
Получаем шаблон проекта со следующей структурой:
Добавляем пару зависимостей (можно указать при генерации шаблона приложения) необходимых для реализации MVC:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>
А так же драйвер БД (в данном случае MySql)
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
<version>6.0.6</version>
</dependency>
Код
Наш сервис будет состоять из одной сущности Product
которая имеет свойства name
и description
. Так же мы опишем Repository
и Controller
. Весь код не для "пользователей" будем писать в пакете com.example.demo.system
, а клиентский код положим в com.example.demo.service
Сущность Product
Создадим файл models.kt
в неймспейсе com.example.demo.system
и добавим туда следующий код:
package com.example.demo.system
import javax.persistence.*
import com.fasterxml.jackson.annotation.*
@Entity // Указывает на то что этот класс описывает модель данных
@Table(name = "products") // Говорим как назвать таблицу в БД
data class Product( // Дата класс нам сгенерирует методы equals и hashCode и даст метод copy
@JsonProperty("name") // Говорим как будет называться свойство в JSON объекте
@Column(name = "name", length = 200) // Говорим как будет называться поле в БД и задаем его длину
val name: String = "", // Объявляем неизменяемое свойство (геттер, а также поле для него будут сгенерированы автоматически) name, с пустой строкой в качестве значения по умолчанию
@JsonProperty("description")
@Column(name = "description", length = 1000)
val description: String = "",
@Id // Сообщяем ORM что это поле - Primary Key
@JsonProperty("id")
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.AUTO) // Также говорим ему что оно - Autoincrement
val id: Long = 0L
)
Репозиторий ProductRepository
Создадим файл repositories.kt
в неймспейсе com.example.demo.system
с тремя строчками кода:
package com.example.demo.system
import org.springframework.data.repository.*
interface ProductRepository : CrudRepository<Product, Long> // Дает нашему слою работы с данными весь набор CRUD операций
Сервисный слой ProductService
Создаем файл ProductService.kt
в неймспейсе com.example.demo.service
со следующим кодом:
package com.example.demo.service
import com.example.demo.system.*
import org.springframework.stereotype.Service
@Service // Позволяем IoC контейнеру внедрять класс
class ProductService(private val productRepository: ProductRepository) { // Внедряем репозиторий в качестве зависимости
fun all(): Iterable<Product> = productRepository.findAll() // Возвращаем коллекцию сущностей, функциональная запись с указанием типа
fun get(id: Long): Product = productRepository.findOne(id)
fun add(product: Product): Product = productRepository.save(product)
fun edit(id: Long, product: Product): Product = productRepository.save(product.copy(id = id)) // Сохраняем копию объекта с указанным id в БД. Идиоматика Kotlin говорит что НЕ изменяемый - всегда лучше чем изменяемый (никто не поправит значение в другом потоке) и предлагает метод copy для копирования объектов (специальных классов для хранения данных) с возможностью замены значений
fun remove(id: Long) = productRepository.delete(id)
}
Контролер ProductsController
Теперь создадим файл controllers.kt
в неймспейсе com.example.demo.system
со следующим кодом:
package com.example.demo.system
import com.example.demo.service.*
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
@RestController // Сообщаем как обрабатывать http запросы и в каком виде отправлять ответы (сериализация в JSON и обратно)
@RequestMapping("products") // Указываем префикс маршрута для всех экшенов
class ProductsController(private val productService: ProductService) { // Внедряем наш сервис в качестве зависимости
@GetMapping // Говорим что экшен принимает GET запрос без параметров в url
fun index() = productService.all() // И возвращает результат метода all нашего сервиса. Функциональная запись с выводом типа
@PostMapping // Экшен принимает POST запрос без параметров в url
@ResponseStatus(HttpStatus.CREATED) // Указываем специфический HttpStatus при успешном ответе
fun create(@RequestBody product: Product) = productService.add(product) // Принимаем объект Product из тела запроса и передаем его в метод add нашего сервиса
@GetMapping("{id}") // Тут мы говорим что это GET запрос с параметром в url (http://localhost/products/{id})
@ResponseStatus(HttpStatus.FOUND)
fun read(@PathVariable id: Long) = productService.get(id) // Сообщаем что наш id типа Long и передаем его в метод get сервиса
@PutMapping("{id}")
fun update(@PathVariable id: Long, @RequestBody product: Product) = productService.edit(id, product) // Здесь мы принимаем один параметр из url, второй из тела PUT запроса и отдаем их методу edit
@DeleteMapping("{id}")
fun delete(@PathVariable id: Long) = productService.remove(id)
}
Настройка приложения
Создадим схему БД с именем demo
и изменим файл application.properties
следующим образом:
#-------------------------
# Database MySQL
#-------------------------
# Какой драйвер будем использовать
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# Имя пользователя для подключения к БД
spring.datasource.username=****
# Пароль подключения к БД
spring.datasource.password=****
# Строка подключения с указанием схемы БД, временной зоны и параметром отключающим шифрование данных
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/demo?serverTimezone=UTC&useSSL=false
#-------------------------
# ORM settings
#-------------------------
# Какой диалект использовать для генерации таблиц
spring.jpa.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect
# Как генерировать таблицы в БД (создавать, обновлять, никак ...)
spring.jpa.hibernate.ddl-auto=create
# Выводим в SQL запросы
spring.jpa.show-sql=true
Все готово можно тестировать
Тестирование
Изменим файл DemoApplicationTests
в неймспейсе com.example.demo
следующим образом:
package com.example.demo
import org.junit.*
import org.junit.runner.RunWith
import org.junit.runners.MethodSorters
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit4.SpringRunner
import org.springframework.web.context.WebApplicationContext
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.web.servlet.setup.MockMvcBuilders.*
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*
@SpringBootTest
@RunWith(SpringRunner::class)
@FixMethodOrder(MethodSorters.NAME_ASCENDING) // Запускать тесты в алфавитном порядке
class DemoApplicationTests {
private val baseUrl = "http://localhost:8080/products/"
private val jsonContentType = MediaType(MediaType.APPLICATION_JSON.type, MediaType.APPLICATION_JSON.subtype) // Записываем http заголовок в переменную для удобства
private lateinit var mockMvc: MockMvc // Объявляем изменяемую переменную с отложенной инициализацией в которой будем хранить mock объект
@Autowired
private lateinit var webAppContext: WebApplicationContext // Объявляем изменяемую переменную с отложенной инициализацией в которую будет внедрен контекст приложения
@Before // Этот метод будет запущен перед каждым тестом
fun before() {
mockMvc = webAppContextSetup(webAppContext).build() // Создаем объект с контекстом придожения
}
@Test
fun `1 - Get empty list of products`() { // Так можно красиво называть методы
val request = get(baseUrl).contentType(jsonContentType) // Создаем GET запрос по адресу http://localhost:8080/products/ с http заголовком Content-Type: application/json
mockMvc.perform(request) // Выполняем запрос
.andExpect(status().isOk) // Ожидаем http статус 200 OK
.andExpect(content().json("[]", true)) // ожидаем пустой JSON массив в теле ответа
}
// Далее по аналогии
@Test
fun `2 - Add first product`() {
val passedJsonString = """
{
"name": "iPhone 4S",
"description": "Mobile phone by Apple"
}
""".trimIndent()
val request = post(baseUrl).contentType(jsonContentType).content(passedJsonString)
val resultJsonString = """
{
"name": "iPhone 4S",
"description": "Mobile phone by Apple",
"id": 1
}
""".trimIndent()
mockMvc.perform(request)
.andExpect(status().isCreated)
.andExpect(content().json(resultJsonString, true))
}
@Test
fun `3 - Update first product`() {
val passedJsonString = """
{
"name": "iPhone 4S",
"description": "Smart phone by Apple"
}
""".trimIndent()
val request = put(baseUrl + "1").contentType(jsonContentType).content(passedJsonString)
val resultJsonString = """
{
"name": "iPhone 4S",
"description": "Smart phone by Apple",
"id": 1
}
""".trimIndent()
mockMvc.perform(request)
.andExpect(status().isOk)
.andExpect(content().json(resultJsonString, true))
}
@Test
fun `4 - Get first product`() {
val request = get(baseUrl + "1").contentType(jsonContentType)
val resultJsonString = """
{
"name": "iPhone 4S",
"description": "Smart phone by Apple",
"id": 1
}
""".trimIndent()
mockMvc.perform(request)
.andExpect(status().isFound)
.andExpect(content().json(resultJsonString, true))
}
@Test
fun `5 - Get list of products, with one product`() {
val request = get(baseUrl).contentType(jsonContentType)
val resultJsonString = """
[
{
"name": "iPhone 4S",
"description": "Smart phone by Apple",
"id": 1
}
]
""".trimIndent()
mockMvc.perform(request)
.andExpect(status().isOk)
.andExpect(content().json(resultJsonString, true))
}
@Test
fun `6 - Delete first product`() {
val request = delete(baseUrl + "1").contentType(jsonContentType)
mockMvc.perform(request).andExpect(status().isOk)
}
}
P.S
Туториал лишь описывает один из многих вариантов реализации подобных приложений и не претендует вообще ни на что. Любая критика только приветствуется, буду рад узнать где я ошибся.
Всем спасибо!
Комментарии (10)
artemmityushov
28.11.2017 11:55Отличная статья, очень хорошо сделано, при это лаконично и понятно. Автору за это респект.
TonyLorencio
28.11.2017 12:29Всё это, конечно, неплохо, хорошая статья для новичков.
Эта небольшая статья ориентированна на тех кто никогда не писал код на Kotlin и не использовал Spring boot.
Казалось бы, ничего лишнего в статье нет, всё чётко и по делу. Но тут одна спринговая магия, хорошо ли давать новичкам код, в котором они в принципе не поймут того, как именно он работает? Можно ли называть REST-сервис на Kotlin простейшим, если в нём уже используется такой монстр как Spring?
qwert_ukg Автор
28.11.2017 12:33Можно ли называть REST-сервис на Kotlin простейшим, если в нём уже используется такой монстр как Spring?
Я имел ввиду по функционалу
artemmityushov
28.11.2017 12:45По крайней мере все по полочкам, последовательно, да и на примерах можно понять как все работает.
dididididi
28.11.2017 15:18Спрингбут КРУД-сервер из коробки дает, там ваще по-моему писать ничего не нужно))) Entity создать и все. Ни репозитариев, ни контроллеров уже не надо.
vlanko
28.11.2017 21:10Теперь я знаю, как в 6 драйвере исправлять временную зону:)
Раньше пользовался 5 версией.
alek_sys
29.11.2017 01:38Пара полезных (надеюсь) вещей по тестам:
FixMethodOrder
– не уверен, насколько полезно, я бы понял если случайный порядок, но намеренно по порядку?@AutoconfigureMockMvc
и потом
@Autowired lateinit var mockMvc: MockMvc
- MockMvc не нужен baseUrl, относительные пути прекрасно распознаются — и не надо переживать про виртуальный путь
- Для JSON есть отличный хелпер, который помогает работать с JSON используя JSONPath, а не сравнение со строкой. Например:
mockMvc.perform(get("/")) .andExpect(jsonPath("$.*.name", hasItems("iPhone 4s")))
qwert_ukg Автор
29.11.2017 07:06@AutoconfigureMockMvc и потом
Спасибо не знал. И exception стал покрасивее выводиться :)
@Autowired lateinit var mockMvc: MockMvc
erlioniel
А можно Github репозиторий с кодом?
qwert_ukg Автор
Добавил