Server-Sent Events в Kotlin: как сделать стриминг данных простым и быстрым

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Забудьте о вечном опросе: встречайте Server-Sent Events в Kotlin

Представьте: вы открываете биржевой терминал, и котировки обновляются сами собой. Или заходите в админку сайта, а там — живая лента количества пользователей онлайн. Никаких кнопок «Обновить», никаких бесконечных запросов к серверу. Просто поток данных, льющийся в реальном времени.

В мире веб-разработки за это отвечают Server-Sent Events (SSE) — протокол, который позволяет серверу самостоятельно отправлять данные клиенту через одно HTTP-соединение. И сегодня я покажу, как реализовать SSE в Kotlin с помощью фреймворка Ktor. Это будет не сухая теория, а полноценный рабочий пример от сервера до клиента.

Что такое Server-Sent Events и почему это круто?

Когда-то давно, чтобы получить свежие данные, разработчики использовали polling (опрос). Клиент каждые N секунд стучался на сервер: «Ну что, есть новости?». Сервер отвечал: «Нет». И так по кругу. Это дико неэффективно: 99% запросов — пустышки, а сервер тратит ресурсы на обработку лишних соединений.

SSE решает проблему кардинально: клиент открывает одно соединение, и сервер сам отправляет данные, когда они появляются. Это как подписка на новости — вы просто ждёте, и вам приносят свежий номер.

Преимущества Server-Sent Events в Kotlin:

  • Простота — работает поверх обычного HTTP, не требует дополнительных библиотек на клиенте (кроме встроенного EventSource в браузере).
  • Автоматическое восстановление — если соединение оборвётся, браузер сам переподключится.
  • Экономия ресурсов — одно соединение вместо десятков.
  • Нативная поддержка в Kotlin — с Ktor и корутинами писать SSE-сервер — одно удовольствие.

Главный минус? SSE — односторонний канал (только от сервера к клиенту). Если нужно общаться в обе стороны — смотрите в сторону WebSocket. Но для уведомлений, лент новостей, котировок, мониторинга — SSE идеален.

Когда стоит использовать SSE в вашем Kotlin-проекте?

  • Лента уведомлений — новые сообщения, комментарии, лайки.
  • Мониторинг и логи — стриминг логов с сервера в реальном времени.
  • Биржевые данные — обновление курсов валют, акций, криптовалют.
  • Прогресс выполнения задач — отчёт о загрузке файла или обработке данных.
  • Системы оповещения — аварийные сигналы, статусы устройств.

Если вы ищете способ передавать обновления с сервера на клиент без головной боли — SSE Kotlin ваш выбор. Давайте перейдём к коду.

Настройка проекта: Ktor + SSE

Для начала создадим новый проект Ktor. Я буду использовать Gradle Kotlin DSL. В build.gradle.kts добавляем зависимости:

val ktor_version = "2.3.7"

dependencies {
    implementation("io.ktor:ktor-server-core:$ktor_version")
    implementation("io.ktor:ktor-server-netty:$ktor_version")
    implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
    implementation("ch.qos.logback:logback-classic:1.4.14")
    implementation("io.ktor:ktor-server-cors:$ktor_version")
}

Обратите внимание: для SSE нам не нужна отдельная библиотека — Ktor поддерживает потоковую передачу данных из коробки через корутины.

Создаём SSE-эндпоинт

Теперь самое интересное — напишем сервер, который будет отправлять клиенту события каждые 2 секунды. Это будет симуляция биржевых котировок.

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.util.*

@Serializable
data class StockPrice(
    val symbol: String,
    val price: Double,
    val timestamp: Long
)

fun main() {
    embeddedServer(Netty, port = 8080) {
        configureRouting()
    }.start(wait = true)
}

fun Application.configureRouting() {
    routing {
        get("/api/stocks") {
            // Устанавливаем заголовки для SSE
            call.response.header("Content-Type", "text/event-stream")
            call.response.header("Cache-Control", "no-cache")
            call.response.header("Connection", "keep-alive")

            val json = Json { ignoreUnknownKeys = true }
            val symbols = listOf("AAPL", "GOOGL", "TSLA", "MSFT")

            // Запускаем бесконечный поток данных
            call.response.writeChannel {
                while (true) {
                    val price = StockPrice(
                        symbol = symbols.random(),
                        price = 100.0 + Random().nextDouble() * 200,
                        timestamp = System.currentTimeMillis()
                    )
                    val data = "data: ${json.encodeToString(StockPrice.serializer(), price)}\n\n"
                    writeStringUtf8(data)
                    flush()
                    delay(2000)
                }
            }
        }
    }
}

Разберём код по косточкам:

  • Content-Type: text/event-stream — обязательный заголовок для SSE.
  • Cache-Control: no-cache — чтобы браузер не кешировал поток.
  • writeChannel — корутинный способ записи в ответ. Мы используем writeStringUtf8 для отправки строк.
  • Формат сообщения — каждая порция данных начинается с data: и заканчивается двумя переводами строки \n\n.

Важный момент: мы не закрываем соединение — цикл while(true) держит его открытым, пока клиент не отключится. Ktor автоматически обработает разрыв соединения.

Улучшаем SSE: идентификаторы и типы событий

Базовая реализация работает, но в реальных проектах нужно больше контроля. Добавим ID события и тип события. Это поможет клиенту обрабатывать разные типы данных и восстанавливать пропущенные сообщения.

var eventId = 0L
call.response.writeChannel {
    while (true) {
        eventId++
        val price = StockPrice(...)
        val message = buildString {
            append("id: $eventId\n")
            append("event: stockUpdate\n")
            append("data: ${json.encodeToString(StockPrice.serializer(), price)}\n\n")
        }
        writeStringUtf8(message)
        flush()
        delay(2000)
    }
}

Теперь клиент может:

  • Игнорировать события, если тип не подходит.
  • При переподключении отправить Last-Event-ID и получить

Блоги

Рекомендаций по книгам