
Со времен релиза 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
Туториал лишь описывает один из многих вариантов реализации подобных приложений и не претендует вообще ни на что. Любая критика только приветствуется, буду рад узнать где я ошибся.
Всем спасибо!
