Когда речь заходит о создании REST-сервера для Kotlin часто на ум приходит фреймворк Ktor (от Jetbrains), использующий важные особенности Kotlin, такие как корутины и DSL-синтаксис. Ktor является модульным решением, для которого созданы расширения для всех наиболее важных аспектов разработки бэкэнда (безопасность, маршрутизация, сериализация данных, применение шаблонов, поддержка сетевых протоколов, управление сессиями, операции с заголовками, извлечение метрик и автоматическая генерация документации по API). Однако это не единственный веб-фреймворк и в некоторых случаях его синтаксис оказывается несколько запутанным (например, операции взаимодействия с неявным объектом call внутри корутин обработки запросов). В этой статье мы рассмотрим альтернативный фреймворк Jooby, который предоставляет схожий с Ktor набор функциональности, но дает больше свободы в выборе механизмов неблокирующей многозадачности и, в ряде случаев, более короткий и явный синтаксис, а также показывает более высокую производительность по результатам тестов.

Проект jooby является решением с открытым исходным кодом, который может использоваться в проектах на JVM-совместимых языках (в том числе Kotlin и Java). Одной из важных особенностей является возможность выбора режима выполнения и использования блокирующих операций, которые запускаются внутри worker executor (как отдельный поток выполнения, который предоставляется веб-сервером). Jooby поддерживает использование в качестве базового сервера Jetty, Netty или проекта Undertow, основанного на java.nio. Также Jooby поддерживает модель MVC для определения контроллера с использованием аннотацией и поддержкой корутин (при этом здесь используется генерация байт-кода для оптимизации обработки запросов). Также jooby может расширен через установку дополнений для обработки assets, подключения к базам данных (jdbc, jooq, …).

Мы сделаем простой API для регистрации и авторизации пользователей и авторизованного доступа к данным их профиля (с возможностью их изменения). Для инициализации приложения можно установить консольную утилиту jooby-cli и создадим проект для Kotlin + Gradle с автоматическим создание Dockerfile. Также можно сгенерировать MVC-приложение через флаг -m (может быть полезно при создании сайтов с использованием jooby + шаблонизация).

После генерации можно увидеть, что jooby добавляет свой плагин io.jooby.run для запуска приложения (мы будем использовать последнюю на момент выхода статью версию 3.0.0.M9, которая требует наличия Java 17). Также обновим версию Gradle до 8.1.1, версию Kotlin до 1.8.21 и sourceCapability = 17.

Модули jooby устанавливаются как дополнительные зависимости. Обязательно установить поддержку веб-сервера (например, io.jooby:jooby-nettyи io.jooby:jooby-http2-netty для поддержки HTTP/2) и желательно добавить библиотеку для тестирования веб-сервера io.jooby:jooby-test.

Самое простое приложение запускает класс реализации, унаследованный от Kooby (Kotlin-вариант Jooby) через метод io.jooby.runApp. Внутри блока инициализации в конструкторе Jooby могут конфигурироваться расширения и описываться HTTP-маршруты и поддерживаемые методы.

package app

import io.jooby.*

class App: Kooby({
  get("/") {
    "Hello World!"
  }
  get("/user/{id}") {
    val id = ctx.path("id").intValue()
    "User $id"
  }.attribute("scope", "user")
})

fun main(args: Array<String>) {
  runApp(args, App::class)
}

Обратите внимание, что возвращаемое значение определяется просто как результат выполнения лямбда-выражения в HTTP-методе. Кроме строковых объектов могут возвращаться списки и Map<String,Any>, которые автоматически конвертируются в JSON. Также может быть вызван метод error для установки статуса ответа (например, установки кода ошибки) или выброшено исключение StatusCodeException. К любому маршруту могут быть привязаны атрибуты, которые могут быть извлечены модулями расширения (например, для проверки контроля доступа через привязку атрибута к названию scope) через ctx.route.attribute.

В блоке инициализации Kooby могут выполняться следующие методы:

  • get, post, head, patch, trace, delete, put, options - соответствующие HTTP-методы, могут принимать аргументы как фрагменты URI (извлекаются через ctx.path). Путь к URL может включать именованные фрагменты, которые допускают определение ограничений по регулярному выражению, например {id:[0-9]+}).

  • path - группировка обработчиков с добавлением префикса (например, все запросы внутри path("/api/v1") будут ожидать соответствующий префикс).

  • route - общее определение маршрута (метод + паттерн)

  • assets - обработка статических ресурсов

  • dispatch - перенос обработчиков в отдельный worker executor

  • coroutine - обертка для обработки корутин во внутренних router / mvc.

  • mvc - установка класса контроллера (методы привязываются к указанному перед классом к @Path префиксу и аннотаций @GET, @POST и другие). Для извлечения аргументов могут извлекаться через аннотации @PathParam, @QueryParam, @HeaderParam, @CookieParam, @SessionParam, @FormParam, @FlashParam. Методы mvc-контроллера могут быть асинхронными (suspend) или возвращать CompletableFuture, Single/Flowable (RxKotlin), Mono/Flux (Reactor). Для использования mvc требуется дополнительно добавить kapt-plugin и разрешить обработку аннотаций для jooby.

plugins {
  //...
  id "io.jooby.run" version "$joobyVersion"
  id "org.jetbrains.kotlin.kapt" version "$kotlinVersion"
}
//...
dependencies {
  //...
  kapt "io.jooby:jooby-apt"
}
kapt {
  arguments {
    arg('jooby.incremental', true)
    arg('jooby.services', true)
    arg('jooby.debug', false)
  }
}

MVC-контроллер (с поддержкой корутин) тогда может выглядеть следующим образом:

@Path("/user")
class UserController {
  @GET("/{id}")
  suspend fun getUser(@PathParam("id") id:Int) = "Hello user $id!"
}

fun main(args: Array<String>) {
  runApp(args) {
    coroutine {
      mvc(UserController())
    }
  }
}

Альтернативой корутинам может быть использование блокирующих функций, но с созданием отдельных worker executor внутри подключенного веб-сервера. Это позволяет использовать синхронные библиотеки без необходимости управления корутинами, но при этом не блокировать основной поток. Для этого мы можем указать режим выполнения в runApp и обернуть необходимые вызовы в блок dispatch (или аннотацию Dispatch над методом контроллера в mvc) или использовать ExecutionMode.WORKER, например:

runApp(args, ExecutionMode.WORKER) {
    get("/delay") {
      Thread.sleep(1000)
      "Result"
    }
  }

Диспетчер можно создать свой собственный, в этом случае в метод dispatch (или аннотацию Dispatch) передается название, зарегистрированное в executor(name, threadExecutor), или объект executor. По умолчанию используется режим ExecutionMode.DEFAULT, который автоматически переключается в режим EVENTLOOP для неблокирующих обработчиков и WORKER в случае блокирующих.

  • decoder - регистрация обработчика входных данных для указанного MIME-типа

  • encoder - регистрация обработчика ответа сервера для указанного MIME-типа

  • sse - создание потока обновлений через Server Side Event

  • install - установка расширения (передается название класса, расширяющего Kooby)

  • error - описание логики при возникновении исключения при обработке route

  • before - действие до обработки запроса (может использоваться для добавления заголовков или внесения других изменений)

  • after - действия после обработки запроса, может использоваться для освобождения ресурсов или модификации ответа. Также может использоваться обработчик в ctx.onComplete.

  • use - определение обработчика, который вызывается как декоратор для обнаруженного route (аргументом в лямбду use передается контекст, который содержит метод next.apply для передачи управление на следующий обработчик). Например, use может использоваться для отслеживания времени выполнения запроса или отладки, его использует механизм плагинов:

use {
    println("Start ${ctx.method} ${ctx.requestPath}")
    val result = next.apply(ctx)
    println("End, content length is ${result.toString().length}")
    result
  }

В Jooby доступны обработчики AccessLogHandler() для отображения логов запросов, SSLHandler() для обработки https-запросов, RateLimitHandler() добавляет ограничение скорости выполнения запросов (требует внешней зависимости Bucket4j), HeadHandler()/TraceHandler() добавляет обработку HEAD-запросов (может быть полезно для работы кэширующих прокси-серверов) или систем с поддержкой OpenTelemetry, CorsHandler() для поддержки обработки CORS-правил, CsrfHandler() для генерации токена защиты от атак (интегрируется в шаблонизаторе как переменная csrf).

Также для генерации содержания доступно подключение модулей шаблонизаторов (Handlebars, Freemarker, Pebble, Rocker, Thymeleaf) через install и дополнительные зависимости.

  • routes - объединение списка обработчика и use-эффектов, которые к ним применяются. Routes могут быть вложенными, при этом неявно в коде инициализации Kooby всегда существует корневой routes (создается в конструкторе Kooby).

  • mount - включение другого роутера (экземпляр Kooby) в текущий роутер (фактически добавляет правила из другого роутера в этот контекст, также может задаваться префикс или предикат для проверки, что роутер должен быть добавлен)

  • serverOptions - конфигурация веб-сервера

Из контекста (доступен в обработчиках и в use) может быть извлечены параметры http-запроса:

  • header(name) - заголовок

  • cookie(name) - информация, сохраненная в именованной cookie (может быть записан в ответ через setResponseCookie)

  • session().attribute(name) - информация из сессии, также можно использовать flash(name) для хранения данных, сохраняющихся кратковременно при выполнении редиректа.

  • path(name) - параметр из запроса (определен через {name})

  • query(name) - параметр из GET-запроса, также может быть размещен в класс через указание generic-типа (например, query<User>(), для переименования полей нужно использовать аннотацию @Named)

  • body() - текстовое содержание POST-запроса, bytes() или stream() - двоичное содержание.

  • form(name) - параметр из POST-запроса

  • multipart() - итератор для обработки запросов с несколькими блоками данных

  • protocol, method, scheme, host, port - соответствующие фрагменты адреса и метод

  • attributes - атрибуты из обработчика (добавляются при определении или в процессе обработки)

  • clientCertificates - переданные от клиента сертификаты

  • file(name) - информация о приложенном к запросу файле

Также в контексте доступно управление ответом сервера (responseCode, responseLength, responseType для MIME-типа и т.д.). Для отправки файла могут быть возвращены объекты InlineFile или AttachedFile.

Все методы, которые возвращают значение (например, query) имеют встроенные механизмы преобразования значения в разные типы (например, intValue() для получения в виде целочисленного типа, также можно в аргументе value указывать значение по умолчанию), кроме этого поддерживается извлечение списка значений при наличии одноименных параметров .toList(Int::class.java).

Для тестирования сервера можно использовать MockRouter(App()), который позволяет выполнять HTTP-запросы и маршрутизировать их в программно запущенный сервер (без привязки к порту). Например, для рассмотренного ранее определения mvc-контроллера можно определить такой тест:

import io.jooby.StatusCode
import io.jooby.test.MockRouter
import org.junit.jupiter.api.Test

import org.junit.jupiter.api.Assertions.assertEquals

class ApiTest {
  @Test
  fun getUser() {
    val router = MockRouter(App())
    router.get("/user/1") {
      assertEquals(StatusCode.OK, it.getStatusCode())
      assertEquals("Hello user 1!", it.value())
    }
  }
}

Также может быть использовано интеграционное тестирование с аннотацией @JoobyTest(App.class), когда запускается полноценный сервер (по умолчанию с привязкой на порт 8911).

Перейдем к разработке нашего API. Для этого нам понадобится добавить несколько модулей:

  • GSON для сериализации/десериализации JSON

  • OpenAPI для генерации документации для API

  • Redis для хранения данных

  • Pac4j для управления токенами безопасности

  • Caffeine для хранения данных сессии в памяти

//...
plugins {
  //...
  id "io.jooby.openAPI" version "$joobyVersion"
}

dependencies {
  implementation "io.jooby:jooby-gson:$joobyVersion"
  implementation "io.jooby:jooby-redis:$joobyVersion"
  implementation "io.jooby:jooby-pac4j:$joobyVersion"
  implementation "org.pac4j:pac4j-jwt:6.0.0-RC7"
  implementation "io.jooby:jooby-caffeine:$joobyVersion"
}

Инициализация модулей выполняется через вызовы install в контексте Kooby-инициализатора:

data class UserInfo(val login:String, val fullname:String, val email:String)

class MyApp : Kooby({
    install(GsonModule())
    install(OpenAPIModule())
    install(RedisModule("redis://localhost:6379"))
    install(Pac4jModule())
    sessionStore = CaffeineSessionStore()
    val redis = require(StatefulRedisConnection::class.java) 
      as StatefulRedisConnection<String, String>
//...
})

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

get("/user/{id}") {
        if (ctx.session("authorized").valueOrNull()==null) {
            throw StatusCodeException(StatusCode.UNAUTHORIZED)
        }
        val id = ctx.path("id").intValue()
        redis.sync().let {
            if (it.exists("user:$id")>0) {
                val login = it.hget("user:$id", "login")
                val fullname = it.hget("user:$id", "fullname")
                val email = it.hget("user:$id", "email")
                UserInfo(login, fullname, email)
            } else {
                null
            }
        } ?: throw StatusCodeException(StatusCode.NOT_FOUND)
    }

Для регистрации пользователя будем использовать POST-запрос с передачей данных в форме и использовать отображение формы в объект:

post("/user") {
    val user = ctx.form(UserRegistration::class.java)
    redis.sync().let { conn ->
        val items = conn.keys("*")
        val keys = items.map { if (it.startsWith("user:")) it.substring(5).toIntOrNull() else null }.filter { it!=null }.map { it!! }
        val max = (keys.maxOrNull() ?: 0) + 1
        conn.hmset("user:$max", mapOf(
            "login" to user.login,
            "password" to user.password,
            "email" to user.password,
            "fullname" to user.fullname
        ))
        mapOf("id" to max)
    }
}

Аналогично могут быть созданы методы для авторизации (в этом случае после проверки соответствия парольной пары устанавливаем флаг в сессии), завершения сеанса (сбрасываем флаг в сессии), изменения профиля (тот же подход, что и при регистрации). Также можно использовать авторизацию с использованием JWT-токена или внешней авторизации через OAuth (с помощью модуля pac4j).

Также с помощью gradle-задачи openAPI можно сгенерировать yaml-файл для автоматического создания клиента для разработанного API или импорта в Swagger:

./gradlew openAPI

YAML-описание создается в build/classes/kotlin/main/app/App.yaml.

Полный исходный текст API опубликован на Github.

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

Все разработчики знают, что код очень часто превращается со временем в "большой комок грязи" (Big Ball of Mud), поддерживать который очень тяжело и дорого. Хочу пригласить вас на бесплатный вебинар, где мы обсудим, как поддерживать чистую архитектуру приложения и контролируемо внедрять изменения. Также мы исследуем библиотеку для реализации бизнес-процессов, написанную на Kotlin. А еще посмотрим пример модуля бизнес-логики, в котором сконцентрированы все требования заказчика.

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