null

Kotlin для начинающих. Пишем микросервисы на Kotlin при помощи мультиплатформенного фреймворка для связанных систем - Ktor

Что такое Ktor?

Ktor (произносится как Кэй-тор) - это созданный с нуля фреймворк, основу которого составляет Kotlin и корутины. С помощью Ktor можно создавать клиентские и серверные приложения, которые будут работать на разных платформах. Фреймворк отлично подходит для приложений, требующих связь по HTTP и/или сокетам. Это могут быть, например, HTTP-бэкенды и RESTful-системы, независимо от того, построены ли они по микросервисному принципу или нет.

Ktor родился под влиянием других фреймворков, таких как Wasabi и Kara, с целью максимально использовать некоторые возможности языка Kotlin, такие как DSL (внутренний предметно-ориентированный язык) и корутины. Если необходимо создать системы, которые будут связаны друг с другом, Ktor отлично подходит для этого и является производительным, асинхронным, многоплатформенным решением.

В настоящее время клиентская часть Ktor работает на всех платформах, на которые ориентирован Kotlin, то есть JVM, JavaScript и Native. Однако, серверная часть Ktor пока ограничена только JVM. В этой статье будет рассмотрено использование Ktor для разработки на стороне сервера.

Ktor для сервера

Рассмотрим пример простейшего серверного приложения, созданного с помощью Ktor:

fun main() {
   val server = embeddedServer(Netty, 8080) {
       routing {
           get("/home") {
               call.respondText("Hello  Ktor!", ContentType.Text.Plain)
           }
       }
   }
   server.start(true)
}

И так, сперва мы создаем экземпляр сервера, который использует Netty в качестве базового движка. Сервер прослушивает порт 8080.

Следующим шагом является определение фактического маршрута для ответа на запрос. В данном случае мы говорим, что при запросе по адресу «/home» сервер должен ответить, отправив «Hello Ktor!» в виде обычного текста.

Наконец, мы запускаем сервер и говорим ему ждать, тем самым предотвращая немедленное завершение работы нашего приложения.

Вот в принципе и весь код.

Подобная краткость отражает суть фреймворка Ktor – создавать приложения, делая это настолько просто, насколько возможно.

Если мы хотим добавить больше маршрутов, в принципе, все, что нам нужно сделать, это определить больше HTTP-глаголов вместе с соответствующими URL-адресами в функции маршрутизации. Например, если мы хотим добавить обработку POST-запроса, мы просто добавим еще одну функцию:

routing {
   get("/") {
       call.respondText("Hello Ktor!", ContentType.Text.Plain)
   }
   post("/home") {
       // некоторая реализация 
   }
}

Везде используются функции

Если вы только начинаете знакомство с Kotlin, вам может быть интересно, что это за конструкции и откуда берутся все эти волшебные слова, такие как call и др. Давайте немного разберемся.

Функции routing, get и post — это функции высшего порядка (то есть такие, которые принимают другие функции в качестве своих параметров или же которые возвращают функции).

В нашем примере вышеперечисленные функции принимают другие функции.

В Kotlin существует соглашение о том, что если последним параметром функции является другая функция, ее можно поместить за скобки (а если это единственный параметр, то скобки опускаются).

В нашем случае routing — это не просто функция высшего порядка, а то, что в Kotlin называется - «лямбда с приемником» (lambda with receiver). То есть такая функция принимает в качестве своего параметра функцию расширения, что, по сути, означает, что всё, что заключено внутри routing, имеет доступ к членам типа Routing.

Этот тип, в свою очередь, имеет функции, такие как get и post, которые, в свою очередь, также являются лямбдами с приемниками, со своими собственными членами, такими как call.

Такая простая комбинация функций и соглашений в Kotlin позволяет создавать элегантные DSL, и в случае Ktor используется, например, как в нашем примере, для определения маршрутов.

Фичи/плагины

Фичи (или текущее принятое название – плагины) – это инструмент, который расширяет возможности Ktor. С помощью фич можно легко добавить определенную функциональность в разрабатываемое приложение, которая не является частью бизнес-логики этого приложения, но в тоже время необходима для его работы. Например, это может быть: кодирование, сериализация, маршрутизация, сжатие, логирование, аутентификация, поддержка cookies и многое другое.

Рассмотрим цепочку событий «запрос-ответ» (request/response pipeline).

Фичи, подключенные (установленные) в эту цепочку, можно рассматривать как перехватчики (interceptors), которые срабатывают на разных этапах цепочки, выполняя присущую им работу. В некоторых других фреймворках подобная функциональность носит название промежуточного ПО или перехватчиков событий.

Использование фичи/плагина может состоять из двух частей:

Первая часть, не обязательная, – это инициализация (Intitalization). Применяется для настройки необходимой функциональности.

Вторая часть – выполнение (Execution). На этом этапе происходит фактический перехват запроса или ответа и выполняется соответствующая работа.

Чтобы задействовать плагин, его просто необходимо установить и по желанию настроить все необходимое. После этого он будет сам выполнять свою часть работы.

Например, если для нашего приложения требуется поддержка согласования контента с кодированием (content negotiation + encoding), достаточно вызвать install(ContentNegotiation) в настройках приложения:

fun Application.jsonSample() {
   install(ContentNegotiation) {
       gson {
           setPrettyPrinting()
           serializeNulls()
       }
   }
   routing {
       get("/customer") {
           val model = Customer(1, "Mary Jane", "mary@jane.com")
           call.respond(model)
       }
   }
}

В приведенном выше коде происходит установка плагина. Присутствует часть инициализации, которая заключается в конфигурировании библиотеки GSon и установке свойств. С помощью этих нескольких строк кода, приложение теперь поддерживает согласование содержимого и кодирование в JSON. Таким образом, вызов /customer возвратит объект Customer в формате JSON.

Возможно, вы заметили, что на упомянутой ранее диаграмме цепочки событий «запрос-ответ», маршрутизация Routing показана так, словно она является фичей. На самом деле, так оно и есть. Routing как и любая другая фича, должна быть установлена. Однако вместо вызова install(Routing) мы обычно используем функцию более высокого порядка routing (как и было показано в примерах выше). На самом деле, если мы посмотрим на реализацию функции routing, то увидим, что она вызывает install(Routing):

fun Application.routing(configuration: Routing.() -> Unit): Routing =
   featureOrNull(Routing)?.apply(configuration) ?: install(Routing, configuration)

Согласование содержимого и маршрутизация — это всего лишь два примера фич. На веб-сайте Ktor перечислены десятки других плагинов, которые поставляются вместе с фреймворком. Однако при необходимости можно самому создать новый плагин и это несложно - по сути, все, что нужно, это реализовать класс, в котором определить фазы инициализации и выполнения.

Структурирование приложений

При разработке приложений, мы обычно создаем ряд конечных точек (endpoints), которые отвечают за различные области приложения. Например, если взять типовую CRM – в ней могут быть следующие конечные точки: Customer, Sales, Proforma. Во многих MVC-фреймворках конечные точки организованы так: создаются отдельные классы с суффиксом Controller, т. е. CustomerController, ProformaController и т.д. И каждый из этих классов привязывается к своим адресам /customer и /proforma соответственно.

А как конечные точки организованы в Ktor?

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

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

А способ, конечно, зависит от ситуации.

Мы можем организовать целостные маршруты в одном файле. Мы можем создавать папки, а затем располагать каждую конечную точку в отдельном файле. Мы можем сгруппировать маршруты по признакам. Все зависит от нас.

Другой нюанс - как определять маршруты? Достаточно ли просто создавать функции верхнего уровня?

Хороший подход - сделать определения маршрутов расширениями класса Route, как показано ниже:

fun Route.home() {
   get("/") {
       call.respondText("Index Page")
   }

}

fun Route.about() {
   get("/about") {
       call.respondText("About Page")
   }
}

Такой подход позволяет удобно расширять маршруты поддерживаемыми методами get, post, put, delete, option, head. Чтобы затем использовать эти определения маршрутов, мы можем просто вызвать каждую функцию в коде инициализации приложения:

fun Application.structureSample() {
   routing {
       home()
       about()
   }
}

Иерархия маршрутов

Ktor также позволяет нам определять маршруты иерархически. Это означает, что вместо того, чтобы делать что-то вроде:

get("/customer/") {
 
}
post("/customer/") {
 
}

Мы можем сделать так:

route("customer") {
   get {

   }
   post {

   }
}

По мимо этого, внутри каждого маршрута можно определять новый URL, если это необходимо:

route("customer") {
   get("/list") {

   }
   post {

   }
}

Отрисовка данных (Rendering data)

В некоторых примерах выше, было показано как в Ktor можно в качестве ответа на запрос возвращать текст или же JSON. А как быть, если нам необходимо возвратить что-то более сложное, например HTML, или использовать движок представлений (viewengine)?

Отрисовка на стороне сервера

Существует множество подходов как в Ktor можно отрисовать данные непосредственно с сервера.

Одним из них является Kotlinx.HTML, который представляет собой DSL для создания статически типизированного HTML. Благодаря Kotlinx.HTML мы можем использовать всю мощь Kotlin, объединяя данные с потоком управления.

Пример ниже демонстрирует проход (итерации) по коллекции элементов:

fun Application.htmlSample() {
   routing {
       get("/html-dsl") {
           call.respondHtml {
               body {
                   h1 { +"HTML" }
                   ul {
                       for (n in 1..10) {
                           li { +"$n" }
                       }
                   }
               }
           }
       }
   }
}

Шаблонизаторы (Template engines)

Сегодня многие приложения, независимо от того, являются ли они одностраничными или нет, используют шаблонизаторы. Из коробки Ktor поддерживает многие из них, включая Freemaker, Thymleaf, Velocity, Mustache и др. Они реализованы как фичи/плагины, поэтому достаточно установить нужный шаблонизатор на этапе инициализации приложения и можно пользоваться.

Работа с параметрами и полями маршрутов

До сих пор мы рассматривали примеры простой маршрутизации. Однако часто реальное веб-приложение работает с информацией, входящей в состав запросов. Это может быть либо часть URL (параметры маршрута), либо поля запроса (все, что следует за знаком «?»), либо часть тела запроса (например, в случае POST и PUT).

Как все эти аспекты реализованы в Ktor?

Параметры маршрута

Доступ к параметрам маршрута можно получить с помощью свойства call.parameters:

get("/customer/{id}") {
       call.respondText(call.parameters["id"].toString())
}

Поля запроса

В случае полей запроса, доступ к ним можно получить с помощью свойства call.request.queryPameters:

get {
call.respondText(call.request.queryParameters["id"].toString())
}

Информация в теле запроса

Ktor поддерживает из коробки работу с данными, переданными через форму (multipartform-data). Для этого используется свойство multipart:

post("/form") {
   val multipart = call.receiveMultipart()
   multipart.forEachPart { part ->
               when (part) {
                   is PartData.FormItem -> appendln("Form field: $part = ${part.value}")
                   is PartData.FileItem -> appendln("File field: $part -> ${part.originalFileName} of ${part.contentType}")
               }
               part.dispose()
           }
}

Использование статической типизации с помощью Location

При определении маршрутов или когда задаются их параметры вместо явного указания значений в виде строк, например, как здесь:

get("/html-dsl")
get("/customer/{id}")

можно использовать строго типизированные определения маршрутов (strongly-typed route definitions).

В Ktor такое определение маршрута называется Location. Для задания Location используются классы. Имя класса определяет название маршрута, а свойства класса – его параметры. По соглашению, имена классов задаются в нижнем регистре:

@Location("/") class index()
@Location("/employee/{id}") class employee(val id: String)

fun Application.locations() {
   install(Locations)
   routing {
       get<index> {
           call.respondText("Routing Demo")
       }
       get<employee> { employee ->
           call.respondText(employee.id)
       }
   }
}

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

Конфигурация

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

Рассмотрим следующий пример:

fun Application.jsonSample() {
   routing {
       get("/customer") {
           val model = Customer(1, "Mary Jane", "mary@jane.com")
           call.respond(model)
       }
   }
}

Этот пример является рабочим, однако в нем нет вызова embeddedServer или server.start, и может показаться что отсутствует основная точка входа в приложение. На самом деле она не отсутствует, а определена где-то в другом месте:

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

Эта единственная строка, по сути, говорит нашему приложению начать использовать движок Netty, но при этом считывать параметры конфигурации из аргументов командной строки, а если аргументов нет, то использовать файл с именем application.conf.

Содержимое этого файла определяется при помощи нотации HOCON (Human-Optimized Config Object Notation, подмножество JSON).

Типичный файл конфигурации выглядит следующим образом:

ktor {
   deployment {
       port = 8080
       port = ${?PORT}
   }
   application {
         modules = [ jsonSample ]
   }
}

где в modules задаются модули приложения, которые будут загружены при старте (в примере выше — это jsonSample).

Допустимо задавать несколько модулей. В таком случае каждый модуль будет представлять свою область функциональности.

 

Конец статьи

======================================================

Данная статья является отредактированным переводом статьи

«Tutorial: Writing Microservices in Kotlin with Ktor — a Multiplatform Framework for Connected Systems» автора Hadi Hariri.