Что такое 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.