Загрузка
Все статьи

Ktor: Server-Sent Events и WebSockets — реальное использование в продакшене

Нужна была real-time уведомления для дашборда. Выбор: SSE или WebSockets? SSE проще (односторонний, HTTP, автопереподключение), WebSockets мощнее (двусторонний). Для уведомлений хватило SSE, но для чата понадобились WebSockets. Рассказываю, как реализовать оба в Ktor и в каких случаях что выбирать.

Задача была классическая: дашборд для операторов, который показывает новые заказы в реальном времени. Первая мысль — WebSockets, всё же это «реальное время». Но потом я посмотрел на требования: сервер отправляет данные, клиент только получает. Никакого двустороннего общения. Зачем стрелять из пушки по воробьям? SSE (Server-Sent Events) подошёл идеально.

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

SSE в Ktor — проще, чем кажется

Ktor не имеет встроенной SSE-поддержки «из коробки» в виде отдельного плагина, но реализация на базе ручного OutgoingContent — дело 30 минут. Я использую подход с ручной отправкой через текстовый стрим:

import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class OrderNotification(
    val orderId: Long,
    val customerName: String,
    val total: Double,
    val type: String = "new_order"
)

class SseBroadcaster {
    private val channels = mutableListOf>()
    private val mutex = Mutex()

    suspend fun subscribe(channel: SendChannel) {
        mutex.withLock { channels.add(channel) }
    }

    suspend fun unsubscribe(channel: SendChannel) {
        mutex.withLock { channels.remove(channel) }
    }

    suspend fun broadcast(event: String) {
        mutex.withLock {
            channels.removeAll { channel ->
                val success = channel.trySend(event).isSuccess
                !success
            }
        }
    }
}

val sseBroadcaster = SseBroadcaster()

И маршрут для подключения клиента:

routing {
    get("/api/notifications/stream") {
        val channel = Channel(Channel.BUFFERED)
        sseBroadcaster.subscribe(channel)

        try {
            call.response.cacheControl("no-cache")
            call.response.header("Connection", "keep-alive")
            call.response.header("Content-Type", "text/event-stream")

            call.respondTextWriter(contentType = ContentType.Text.EventStream) {
                // Отправляем начальное событие подтверждения
                write("event: connected\ndata: {\"status\":\"ok\"}\n\n")
                flush()

                for (event in channel) {
                    write("event: order\ndata: $event\n\n")
                    flush()
                }
            }
        } finally {
            sseBroadcaster.unsubscribe(channel)
            channel.close()
        }
    }
}

Ключевой момент: SSE работает поверх обычного HTTP, поэтому прокси-серверы, балансировщики и CDN с ним дружат из коробки. Не нужно настраивать upgrade-запросы, как для WebSockets.

WebSockets в Ktor — для двустороннего общения

Когда понадобился чат, SSE уже не хватало — клиент должен отправлять сообщения. Подключаем плагин WebSockets:

install(WebSockets) {
    pingPeriod = Duration.ofSeconds(15)
    timeout = Duration.ofSeconds(15)
    maxFrameSize = Long.MAX_VALUE
    masking = false
}

val chatRooms = mutableMapOf>()

Обработчик WebSocket-соединения:

routing {
    webSocket("/ws/chat/{roomId}") {
        val roomId = call.parameters["roomId"] ?: return@webSocket close(
            CloseReason(CloseReason.Codes.PROTOCOL_ERROR, "Missing roomId")
        )

        val room = chatRooms.getOrPut(roomId) { mutableListOf() }
        room.add(this)

        try {
            send(Json.encodeToString(ChatMessage(
                type = "system",
                text = "Оператор подключился к чату"
            )))

            for (frame in incoming) {
                when (frame) {
                    is Frame.Text -> {
                        val message = Json.decodeFromString(frame.readText())
                        chatService.saveMessage(roomId.toLong(), message)

                        val outgoing = Json.encodeToString(message)
                        room.forEach { session ->
                            session.send(outgoing)
                        }
                    }
                    is Frame.Close -> {
                        println("Клиент отключился: ${frame.readReason()}")
                    }
                    else -> {}
                }
            }
        } catch (e: Exception) {
            println("WebSocket ошибка: ${e.message}")
        } finally {
            room.remove(this)
        }
    }
}

@Serializable
data class ChatMessage(
    val type: String,
    val text: String,
    val senderId: Long? = null,
    val timestamp: Long = System.currentTimeMillis()
)

Сравнение на практике

После трёх месяцев в проде могу сравнить:

SSE: односторонний (сервер к клиенту), HTTP, автопереподключение через EventSource API в браузере, дружит с любыми прокси, проще в дебаге (видно в DevTools как обычные HTTP-запросы). Минусы — нет бинарных данных, одно соединение равно один поток событий, браузер ограничивает до 6 соединений на домен.

WebSockets: двусторонний, бинарные данные, одно TCP-соединение вместо HTTP-опросов, ниже latency. Минусы — нужен sticky sessions за балансировщиком, сложнее дебажить, не все CDN пропускают, при обрыве переподключение нужно писать вручную.

Мой эвристический правило: если сервер только отправляет данные — SSE. Если клиент тоже отправляет — WebSockets. Исключение: если нужно отправлять бинарные данные (файлы, стриминг) — только WebSockets.

Продакшен-моменты, о которых молчат

Первое — backpressure. Когда мы добавили 50-го подписчика на SSE, канал начал буферизовать события и через минуту получали OutOfMemory. Решение: ограничили размер буфера и при переполнении отключали самых старых подписчиков с HTTP 503.

Второе — мониторинг. В проде нужно знать, сколько активных SSE-соединений и WebSocket-сессий. Мы добавили метрики через Micrometer и дашборд в Grafana. Если соединений вдруг становится ноль — значит, что-то сломалось.

Третье — nginx перед Ktor. Для SSE нужно добавить proxy_buffering off и proxy_cache off, иначе nginx будет буферизовать события и клиент не получит их в реальном времени. Для WebSockets — proxy_set_header Upgrade $http_upgrade и proxy_read_timeout 86400.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Micronaut vs Spring Boot — лёгкий фреймворк для Kotlin-микросервисов

Сравниваю Micronaut и Spring Boot для Kotlin-микросервисов: стартовое время, потребление памяти, холодный старт и реальный опыт миграции одного из сервисов.

12 июня 2026 11 мин

Kotlin: sealed interfaces и when-выражения — паттерн-матчинг как в Rust

Как sealed interfaces из Kotlin 1.5+ вместе с when-выражениями дают мощный паттерн-матчинг, похожий на Rust enum+match, и почему мы перешли на ADT для доменных событий.

11 июня 2026 9 мин

Kotlinx Serialization + Protobuf — быстрая сериализация для микросервисов

Разбираюсь с накладными расходами JSON-сериализации при высоких нагрузках и показываю, как интегрировать Protobuf с kotlinx-serialization в микросервисной архитектуре на Kotlin.

10 июня 2026 10 мин