Со времен релиза Kotlin прошло уже более года, да и Spring boot претерпел изменения. Наткнувшись на статью о том как написать простой RESTful сервис используя Kotlin и Spring boot, захотелось написать о том как же это можно сделать сегодня.


Эта небольшая статья ориентированна на тех кто никогда не писал код на Kotlin и не использовал Spring boot.


Подготовка проекта


Нам понадобится:



Для начала идем на сайт 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


Туториал лишь описывает один из многих вариантов реализации подобных приложений и не претендует вообще ни на что. Любая критика только приветствуется, буду рад узнать где я ошибся.


source


Всем спасибо!

Комментарии (10)


  1. erlioniel
    28.11.2017 11:30

    А можно Github репозиторий с кодом?


    1. qwert_ukg Автор
      28.11.2017 12:26

      Добавил


  1. artemmityushov
    28.11.2017 11:55

    Отличная статья, очень хорошо сделано, при это лаконично и понятно. Автору за это респект.


  1. TonyLorencio
    28.11.2017 12:29

    Всё это, конечно, неплохо, хорошая статья для новичков.


    Эта небольшая статья ориентированна на тех кто никогда не писал код на Kotlin и не использовал Spring boot.

    Казалось бы, ничего лишнего в статье нет, всё чётко и по делу. Но тут одна спринговая магия, хорошо ли давать новичкам код, в котором они в принципе не поймут того, как именно он работает? Можно ли называть REST-сервис на Kotlin простейшим, если в нём уже используется такой монстр как Spring?


    1. qwert_ukg Автор
      28.11.2017 12:33

      Можно ли называть REST-сервис на Kotlin простейшим, если в нём уже используется такой монстр как Spring?

      Я имел ввиду по функционалу


    1. artemmityushov
      28.11.2017 12:45

      По крайней мере все по полочкам, последовательно, да и на примерах можно понять как все работает.


  1. dididididi
    28.11.2017 15:18

    Спрингбут КРУД-сервер из коробки дает, там ваще по-моему писать ничего не нужно))) Entity создать и все. Ни репозитариев, ни контроллеров уже не надо.


  1. vlanko
    28.11.2017 21:10

    Теперь я знаю, как в 6 драйвере исправлять временную зону:)
    Раньше пользовался 5 версией.


  1. alek_sys
    29.11.2017 01:38

    Пара полезных (надеюсь) вещей по тестам:


    1. FixMethodOrder – не уверен, насколько полезно, я бы понял если случайный порядок, но намеренно по порядку?
    2. @AutoconfigureMockMvc и потом
      @Autowired
      lateinit var mockMvc: MockMvc
    3. MockMvc не нужен baseUrl, относительные пути прекрасно распознаются — и не надо переживать про виртуальный путь
    4. Для JSON есть отличный хелпер, который помогает работать с JSON используя JSONPath, а не сравнение со строкой. Например:
      mockMvc.perform(get("/"))
                  .andExpect(jsonPath("$.*.name", hasItems("iPhone 4s")))


  1. qwert_ukg Автор
    29.11.2017 07:06

    @AutoconfigureMockMvc и потом
    @Autowired
    lateinit var mockMvc: MockMvc
    Спасибо не знал. И exception стал покрасивее выводиться :)