Files
Lab8-Prog/docs stuff/ARCHITECTURE.md
T
2026-06-12 17:42:16 +03:00

33 KiB
Raw Blame History

Архитектура gui-client

1. Обзор проекта

Проект состоит из четырёх Gradle-модулей:

common/       — общие модели (HumanBeing, Coordinates, Car, WeaponType)
                и сетевые типы (Request, Response, AuthRequest)
client/       — низкоуровневый UDP-клиент (NetworkManager)
server/       — сервер (ForkJoinPool, Exposed ORM, PostgreSQL)
gui-client/   — JavaFX-клиент (наш модуль)
gateway/      — UDP/TCP-шлюз

Модуль gui-client — это толстый JavaFX-клиент, который:

  • аутентифицируется (login / register) через UDP
  • отображает коллекцию HumanBeing в таблице и на Canvas
  • позволяет добавлять, редактировать, удалять объекты
  • автоматически обновляет данные каждые 3 секунды (polling)
  • поддерживает 4 языка (ru, nl, hu, en_AU) без перезапуска
  • собирается в fat JAR (shadowJar)

2. Почему JavaFX + Kotlin, а не что-то ещё

JavaFX выбран, потому что:

  • Это требование лабораторной работы — использовать JavaFX.
  • Даёт полноценный desktop-GUI: TableView, Canvas, модальные окна, CSS-styling.
  • Работает вне браузера, что важно для UDP-сокетов (браузеры не поддерживают произвольные UDP).

Kotlin выбран вместо Java, потому что:

  • Значительно короче (data class, scope functions, лямбды, trailing lambda).
  • Полная совместимость c JavaFX (прозрачная interop через Kotlin → JVM байткод).
  • Coroutines для асинхронной работы с UDP (без callback-hell).
  • Kotlin 2.2.0 с новым компилятором K2.

3. Структура gui-client

gui-client/src/main/kotlin/
├── Main.kt                       — точка входа
├── localization/
│   └── AppLocale.kt              — i18n (4 локали, динамическое переключение)
├── model/
│   ├── Session.kt                — глобальное состояние (singleton)
│   └── HumanBeingProperty.kt     — observable-обёртка над HumanBeing
├── network/
│   └── ClientProvider.kt         — coroutine-обёртка над NetworkManager
├── controller/
│   ├── AuthController.kt         — логика аутентификации
│   └── MainController.kt         — логика главного экрана (polling, фильтры, команды)
├── view/
│   ├── AuthView.kt               — окно входа / регистрации
│   ├── MainView.kt               — главное окно (таблица + Canvas + тулбар)
│   ├── EditDialog.kt             — модальное окно добавления / редактирования
│   └── CollectionCanvas.kt       — Canvas-визуализация
└── util/
    └── StreamUtils.kt            — Streams API filter + sort

4. Почему такая архитектура?

4.1. Разделение на View / Controller

Выбран MVC-подобный паттерн (не MVVM, не MVP):

  • View (пакет view/) — только построение UI-дерева и обработка пользовательских событий. Никакой бизнес-логики, никаких прямых вызовов сети.
  • Controller (пакет controller/) — бизнес-логика, асинхронные операции, работа с Session и ClientProvider.
  • Model (пакет model/) — состояние и observable-свойства.

Почему не MVVM:

  • JavaFX TableView из коробки требует ObservableList + cell value factories, что естественно ложится на простые property-обёртки.
  • Добавление ViewModel-слоя (ещё один уровень абстракции) неоправданно усложнило бы код для desktop-приложения такого размера.
  • Контроллер в нашем случае — не JavaFX Controller (не @FXML), а обычный класс, который вызывается из View. Это разделение достаточно для тестируемости и переиспользования.

Почему c (controller) строчной, а v (view) строчной — Kotlin style (package names lowercase).

4.2. Session — singleton, а не dependency injection

object Session {
    var client: ClientProvider? = null
    var token: String = ""
    var userId: UUID? = null
    var username: String = ""
    val collection: ObservableList<HumanBeingProperty> = FXCollections.observableArrayList()
    ...
}
  • Kotlin object — это потокобезопасный singleton на уровне JVM.
  • Все компоненты (View, Controller, Canvas, EditDialog) читают и пишут состояние через Session.*.
  • Альтернатива (DI: Guice, Koin, Dagger) неоправданна для такого масштаба — DI имеет смысл, когда есть десятки классов с глубокими графами зависимостей. У нас 4 view, 2 controller, несколько утилит.
  • ObservableList<HumanBeingProperty> — единственный источник истины (single source of truth). Парсинг ответа сервера (show) обновляет именно её, Canvas и TableView подписаны на её изменения.

4.3. HumanBeingProperty — observable-обёртка

class HumanBeingProperty(humanBeing: HumanBeing) {
    val name = SimpleStringProperty(humanBeing.name)
    val coordinateX = SimpleObjectProperty<Long>(humanBeing.coordinates.x)
    val weaponType = SimpleObjectProperty<WeaponType?>(humanBeing.weaponType)
    ...
}

Почему не использовать HumanBeing напрямую?

  • TableColumn<HumanBeingProperty, String> требует cellValueFactory, который возвращает ObservableValue<String>. Поле SimpleStringProperty это даёт.
  • Если бы мы использовали HumanBeing напрямую, пришлось бы оборачивать каждое чтение в SimpleObjectProperty внутри cellValueFactory, что создаёт временные объекты при каждом обновлении.
  • HumanBeingProperty — это value object, который живёт столько же, сколько и элемент в таблице. Его поля — это binding targets.
  • toHumanBeing() позволяет преобразовать обратно в модель для отправки на сервер.

4.4. ClientProvider — coroutine-обёртка над blocking NetworkManager

class ClientProvider(host: String, port: Int) {
    private val net = ClientNetworkManager(host, port)

    suspend fun auth(login: String, password: String, type: AuthType): AuthResponse? =
        withContext(Dispatchers.IO) {
            net.sendRequest(AuthRequest(login, md5Hash(password), type))
        }

    suspend fun execute(commandName: String, ...): Response? =
        withContext(Dispatchers.IO) {
            net.sendRequest(Request(...))
        }
}
  • NetworkManager (из модуля client) — blocking API. Он использует DatagramChannel с блокирующими вызовами send/receive.
  • ClientProvider оборачивает блокирующие вызовы в withContext(Dispatchers.IO) — стандартный Kotlin-паттерн для bridge между blocking и coroutine мирами.
  • Все контроллеры работают с Session.client?.execute(), который возвращает suspend и запускается в scope.launch { ... } контроллера.

Почему не сделать NetworkManager сам async:

  • client — отдельный модуль, его API жёстко задан (требование лабораторной — "модуль client"). Менять его нельзя без согласования.
  • Coroutine-обёртка изолирует изменения: если API NetworkManager изменится, правим только ClientProvider, а не все контроллеры.

4.5. Two-stage аутентификация

AuthController отделён от AuthView:

  • View собирает данные (host, port, username, password, login/register).
  • View вызывает controller.authenticate(...).
  • Controller в scope.launch создаёт ClientProvider, отправляет AuthRequest, и результат передаёт через callback (Boolean, String) -> Unit.

Callback, а не возврат значения:

  • authenticate не может вернуть результат синхронно (сеть блокирует).
  • Она не может вернуть suspend — тогда View пришлось бы работать с coroutines (Parental ответственность View — показывать UI, не управлять потоками).
  • Callback безопасно вызывается через Platform.runLater, чтобы UI обновлялся из JavaFX Application Thread.

Почему Session.userId = provider.getUUID() устанавливается именно в AuthController:

  • NetworkManager.setUUID(id) хранит UUID внутри net-слоя.
  • Session.userId — это глобальное состояние, нужное для:
    • EditDialog.onOk() — установка ownerId для новых объектов.
    • Session.isOwnObject() — проверка владения для edit/delete.
  • Устанавливать его позже (в View) было бы поздно — к моменту открытия EditDialog userId уже должен быть известен.
  • kotlin.runCatching { UUID.fromString(it) }.getOrNull() — безопасный парсинг: если сервер вернёт кривой UUID, клиент не упадёт.

4.6. MainController — polling + команды

class MainController {
    private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
    private var pollingJob: Job? = null

    fun start() {
        Session.collection.addListener(collectionListener)
        startPolling()
    }

    private fun startPolling() {
        pollingJob = scope.launch {
            while (isActive) {
                delay(3000)
                refreshCollection()
            }
        }
    }

Почему polling (pull), а не server-sent events (push):

  • Сервер работает поверх UDP. Server-sent events требуют TCP или WebSocket.
  • UDP не гарантирует порядок доставки. Если мы полагаемся на пуш-уведомления, можем пропустить обновление.
  • Polling каждые 3 секунды — разумный компромисс: UI обновляется достаточно быстро для desktop-приложения, нагрузка на сервер минимальна.
  • delay(3000) в корутине — не блокирует поток (в отличие от Thread.sleep).

Почему SupervisorJob:

  • Если refreshCollection() упадёт с исключением, это не отменит весь scope и не остановит polling.
  • SupervisorJob изолирует ошибку внутри одной корутины.

Как работает refreshCollection():

  1. Выполняет Session.client?.execute("show") — блокирующий вызов, обёрнутый в Dispatchers.IO.
  2. Парсит текстовый ответ (строка → список HumanBeingProperty).
  3. Сравнивает ID текущей коллекции с ID из ответа:
    • toRemove — элементы, которых нет в ответе.
    • toAdd — новые элементы.
    • toUpdate — элементы, чьё name изменилось.
  4. Все изменения через Platform.runLater.

Парсинг ответа — почему не JSON:

  • Ответ от сервера — это строковое представление toString() объекта HumanBeing.
  • Формат: HumanBeing(id=..., name=..., ...).
  • Парсинг через regex (extractValue) — да, это хрупко. Но:
    • Изменять формат на стороне сервера (переписывать show) — нельзя (требование лабораторной).
    • JSON-парсинг на клиенте потребовал бы координации схемой с сервером.
    • Regex-парсинг достаточно надёжен для стабильного формата вывода.

4.7. StreamUtils — функциональный filter/sort

object StreamUtils {
    fun applyFiltersAndSort(
        items: List<HumanBeingProperty>,
        filters: Map<String, String>,
        comparators: List<Comparator<HumanBeingProperty>>,
    ): ObservableList<HumanBeingProperty> {
        var stream = items.stream()
        for ((key, query) in filters) {
            if (query.isNotBlank()) {
                stream = stream.filter { prop ->
                    val fieldValue = when (key) { ... }
                    fieldValue.lowercase().contains(lower)
                }
            }
        }
        if (comparators.isNotEmpty()) {
            val combined = comparators.reduce { a, b -> a.thenComparing(b) }
            stream = stream.sorted(combined)
        }
        return stream.collect(Collectors.collectingAndThen(
            Collectors.toList(),
            { FXCollections.observableArrayList(it) }
        ))
    }
}

Почему java.util.stream.Stream, а не Kotlin Sequences:

  • Это прямое требование лабораторной — использовать Streams API.
  • Stream.filter / sorted — ленивые операции. Композиция нескольких .filter + sorted не создаёт промежуточных коллекций.
  • collect(Collectors.collectingAndThen(toList(), FXCollections::observableArrayList)) — создаёт копию списка, которую можно безопасно вставить в TableView.

Почему перебираем Map<String, String>, а не строим Predicate для каждого поля:

  • Фильтры динамические: пользователь вводит текст в 11 полях, каждое применяется как .contains(lowercase).
  • Использование рефлексии или property references могло бы быть элегантнее, но:
    • Рефлексия в JavaFX Property — избыточна (O(n) вызовов через MethodHandle).
    • when (key) — читаемый и однозначный диспатчинг. Да, это switch на 11 кейсов, но он стабилен: если добавится поле, компилятор не даст забыть кейс (в Kotlin нет exhaustiveness для when со String, но визуально заметно).

4.8. AppLocale — i18n без ResourceBundle

object AppLocale {
    val currentLocale: ObjectProperty<Locale> = SimpleObjectProperty(Locale("ru"))

    private val strings: Map<Locale, Map<String, String>> = mapOf(
        Locale("ru") to mapOf(...),
        Locale("nl") to mapOf(...),
        ...
    )

    fun bind(property: Property<String>, key: String) {
        property.value = get(key)
        currentLocale.addListener { _, _, _ ->
            property.value = get(key)
        }
    }
}

Почему .properties файлы не используются:

  • Требование лабораторной — "i18n должен храниться прямо в коде" (в классе).
  • Это упрощает сборку: не нужно копировать ресурсы в JAR (нет processResources).
  • Изменение строк не требует сборки проекта — просто правка Kotlin-файла.

Почему ObjectProperty<Locale>:

  • При смене языка нужно перерисовать ВСЕ UI-элементы, которые уже привязаны к строкам.
  • bind(property, key) регистрирует listener, который обновляет свойство при смене currentLocale.
  • Можно было бы пересоздавать все сцены при смене языка. Но bind — это точечное обновление: меняется только текст, без потери состояния полей ввода, выделения в таблице, позиции скролла.

Locale en_AU:

  • Выбран для юмора (Ryan Gosling + Australian English).
  • "Carn't" для car.cool, "Server's cactus", "Check ya credentials" и т.д.
  • Это демонстрирует кастомную локаль, не ограничиваясь стандартными en_US / en_GB.

4.9. EditDialog — модальное окно

class EditDialog(private val owner: Window, private val existing: HumanBeing? = null) {
    fun showAndWait(): HumanBeing? {
        dialog.initModality(Modality.WINDOW_MODAL)
        dialog.initOwner(owner)
        ...
        dialog.showAndWait()
        return result
    }
}

Почему return-значение через result, а не callback:

  • Диалог модальный — он блокирует ввод в родительском окне, пока открыт.
  • showAndWait() «замораживает» выполнение до закрытия диалога.
  • result записывается в onOk() при нажатии OK, диалог закрывается, и showAndWait() возвращает управление.
  • Код в MainView.onAdd() выглядит последовательно: val humanBeing = dialog.showAndWait() ?: return; controller.executeCommand(...) — нет вложенных колбэков.

Валидация:

  • Каждое поле проверяется при OK (а не по мере ввода).
  • Это проще: не нужно показывать/скрывать ошибки на каждое нажатие клавиши.
  • Если какое-то поле невалидно, показывается Alert (ошибка), диалог остаётся открытым.

Почему Session.userId ?: return:

  • При добавлении нового объекта ownerId должен быть установлен.
  • Если Session.userId == null (пользователь не залогинен или произошла ошибка), создавать объект бессмысленно.
  • run { showError(...); return } — явная диагностика вместо молчаливого игнора.

4.10. CollectionCanvas — анимация через AnimationTimer

private val renderTimer: AnimationTimer = object : AnimationTimer() {
    override fun handle(now: Long) {
        animationTime = now / 1_000_000_000.0
        render()
    }
}

Почему Canvas + AnimationTimer, а не JavaFX Shapes (Circle, Rectangle):

  • 50+ объектов на Canvas — один draw call на объект. Shapes создают отдельный Node для каждого — overhead для Scene Graph.
  • AnimationTimer вызывает handle() при каждом кадре рендера (60 FPS), что нужно для плавного свечения.
  • Glow-эффект — это полупрозрачный овал позади основного, чья alpha пульсирует по sin(time * 3.0). Никаких JavaFX effects (DropShadow, Glow) — они бы грузили GPU без необходимости.

Почему ручное преобразование координат (world → screen):

  • worldToScreenX(x) = x * scaleX + offsetX — линейное преобразование из мировых координат в пиксели Canvas.
  • Автомасштаб: дельта между min и max по X/Y определяет scaleX/scaleY.
  • При одном элементе (min == max) используется defaultRange = 200.0, чтобы точка не уезжала в бесконечность.
  • Цвет владельца: ownerColor(ownerId) = Color.hsb(hashCode % 360, 0.7, 0.9). Хэш UUID детерминирован — один и тот же владелец всегда одного цвета.

Почему isResizable() = true и prefWidth/Height:

  • Canvas в JavaFX по умолчанию не участвует в Layout (всегда сам задаёт размер).
  • Переопределение isResizable, prefWidth, prefHeight заставляет Canvas вести себя как Node, который можно растягивать через widthProperty().bind(...).
  • Без этого Canvas бы не подстраивался под SplitPane при изменении размеров окна.

4.11. Парсинг parseShowResponse

private fun parseShowResponse(message: String): List<HumanBeingProperty>? {
    val lines = message.lines().filter { it.isNotBlank() }
    var currentLines = mutableListOf<String>()
    var braceCount = 0
    for (line in lines) {
        currentLines.add(line)
        braceCount += line.count { it == '{' }
        braceCount -= line.count { it == '}' }
        if (braceCount == 0 && currentLines.isNotEmpty()) {
            val parsed = parseHumanBeingBlock(currentLines)
            if (parsed != null) items.add(parsed)
            currentLines.clear()
        }
    }
}

Почему brace-счётчик, а не разбивка по строкам:

  • HumanBeing содержит вложенные объекты Coordinates и Car. Их toString() тоже содержит фигурные скобки.
  • Простое разделение по \n\n не сработает, если у Coordinates будет многострочный вывод.
  • Brace-счётчик отслеживает вложенность: открывающая { увеличивает счётчик, закрывающая } уменьшает. Когда braceCount == 0 — блок завершён.

4.12. MainView — сборка UI

private fun buildTablePane(): Node {
    val columns = listOf(
        "name" to { p: HumanBeingProperty -> p.name.value },
        "coordinates.x" to { p: HumanBeingProperty -> p.coordinateX.value.toString() },
        ...
    )
    for ((i, col) in columns.withIndex()) {
        val headerLabel = Label()
        AppLocale.bindLabel(headerLabel, "table.$key")
        headerLabel.setOnMouseClicked { toggleSort(key) }
        ...
        val tableCol = TableColumn<HumanBeingProperty, String>()
        tableCol.cellValueFactory = Callback { SimpleObjectProperty(extractor(it.value)) }
        ...
    }
}

Почему программное построение UI, а не FXML:

  • FXML удобен для крупных форм со сложной вложенностью. У нас — один BorderPane с ToolBar, SplitPane и HBox.
  • FXML требует @FXML аннотаций, отдельного контроллера, FXMLLoader с ClassLoader.
  • Для 300 строк UI проще писать на Kotlin: типизировано, нет магических строк #onLogin, компилятор проверяет вызовы.

Почему 11 отдельных TextField для фильтров, а не один поиск:

  • Требование: фильтр по каждому полю отдельно.
  • Пользователь может искать по имени И координате X одновременно.
  • Map<String, String>, где key = имя поля, value = строка поиска, передаётся в StreamUtils.applyFiltersAndSort.

Сортировка по клику на заголовок:

  • Клик по headerLabel переключает сортировку для колонки.
  • toggleSort(key) меняет sortAscending и создаёт Comparator<HumanBeingProperty>.
  • Компаратор сравнивает строковые представления значений — это универсально, работает для всех типов (String, Int, Double, Boolean).
  • padStart для чисел: 1.toString().padStart(20, '0') = "00000000000000000001". Это нужно, чтобы лексикографическая сортировка строк дала правильный числовой порядок.

4.13. MainView — команды add / edit / delete

private fun onAdd() {
    val dialog = EditDialog(stage.scene.window)
    val humanBeing = dialog.showAndWait() ?: return
    controller.executeCommand(name = "add", humanBeing = humanBeing) { success, message ->
        if (success) controller.requestRefresh() else showError(message)
    }
}

controller.requestRefresh():

  • При вызове requestRefresh() контроллер сразу запускает refreshCollection().
  • После успешной операции (add/update/delete) новый объект появляется в таблице немедленно, без ожидания 3-секундного poll.
  • Platform.runLater внутри refreshCollection гарантирует, что обновление коллекции происходит в JavaFX Application Thread.

4.14. Сборка shadowJar

// build.gradle.kts
plugins {
    kotlin("jvm")
    id("org.jetbrains.compose")
    id("com.gradleup.shadow")
}

tasks.shadowJar {
    archiveBaseName.set("Lab8-gui-client")
    archiveVersion.set("1.0")
    archiveClassifier.set("all")
    mergeServiceFiles()
}
  • com.gradleup.shadow — создаёт fat JAR со всеми зависимостями (Kotlin stdlib, JavaFX, Exposed, Jackson, Log4j).
  • mergeServiceFiles() — объединяет META-INF/services из всех JAR-ов, иначе не работают JavaFX ServiceLoader и JDBC Driver.
  • JavaFX modules (javafx.controls, javafx.graphics) — добавляются в runtime через Gradle Compose plugin.

5. Data Flow

5.1. Аутентификация

AuthView.onLogin()
  → читает host, port, username, password, isLogin
  → controller.authenticate(host, port, username, password, isLogin) { success, msg -> ... }
    → scope.launch(Dispatchers.Default)
      → Session.client?.close()
      → ClientProvider(host, port)       // создаётся новый провайдер
      → provider.auth(login, password, type)
        → withContext(Dispatchers.IO)
          → NetworkManager.sendRequest(AuthRequest(...))
            → DatagramChannel.send() / receive() (blocking)
      → response.success?
        → Session.client = provider
        → Session.username = username
        → Session.userId = provider.getUUID()
        → Platform.runLater { onResult(true, "") }
          → AuthView: stage.close(), onSuccess?.invoke()
            → Main.Stage → MainView.show()

5.2. Получение коллекции (polling)

MainController.start()
  → startPolling()
    → scope.launch { while(isActive) { delay(3000); refreshCollection() } }

refreshCollection()
  → Session.client?.execute("show")
    → withContext(Dispatchers.IO)
      → NetworkManager.sendRequest(Request("show", ...))
  → parseShowResponse(response.message)
    → lines → brace-счётчик → parseHumanBeingBlock → HumanBeingProperty
  → Platform.runLater {
      Session.collection.removeAll(toRemove)
      Session.collection.addAll(toAdd)
      Session.collection[idx] = updated
    }
      → TableView обновляется (items привязан к filteredList ← Session.collection + фильтры)
      → Canvas перерисовывается (collectionListener → scheduleRender())

5.3. Добавление объекта

MainView.onAdd()
  → EditDialog(stage.scene.window).showAndWait()
    → модальный диалог → пользователь заполняет поля → OK
    → onOk(): валидация → result = HumanBeing(...) → dialog.close()
    → возвращает HumanBeing
  → controller.executeCommand("add", humanBeing = humanBeing)
    → scope.launch {
        Session.client?.execute("add", [], humanBeing)
          → withContext(Dispatchers.IO)
            → NetworkManager.sendRequest(Request("add", ..., humanBeing))
      }
  → callback { success, message ->
      if (success) controller.requestRefresh()
      else showError(message)
    }

5.4. Фильтрация и сортировка

Пользователь вводит текст в filterField
  → filterField.textProperty().addListener { _, _, _ → applyFilters() }
    → controller.updateFilters(filters)
      → currentFilters = filters
      → reapplyFiltersAndSort()
        → StreamUtils.applyFiltersAndSort(Session.collection, currentFilters, currentComparators)
          → stream.filter(...).sorted(...).collect(collectingAndThen(...))
        → Platform.runLater { filteredList.setAll(result) }

6. Почему именно такие решения: сводная таблица

Решение Альтернатива Почему выбрано это
MVC (View → Controller) MVVM, MVP Простота, JavaFX TableView из коробки работает с ObservableList
Session singleton DI (Koin, Dagger) 2 controller + 4 view — DI избыточен
HumanBeingProperty Прямое использование HumanBeing Observable для TableView
ClientProvider (coroutine wrapper) Сделать NetworkManager suspend Нельзя менять модуль client; wrapping = изоляция
Polling 3s Push / WebSocket UDP, сервер не поддерживает push
StreamUtils (Streams API) Kotlin Sequences Требование лабораторной
i18n в коде (Map) .properties файлы Требование лабораторной
ObjectProperty + bind Пересоздание Scene Сохраняет состояние UI при смене языка
Canvas + AnimationTimer Shapes + Timeline Производительность при 50+ объектах
Regex-парсинг ответа show JSON Формат ответа сервера фиксирован и не-JSON
Программный UI FXML Малый размер UI, типизация, без FXMLLoader
shadowJar jlink / jpackage Простота, одношаговая сборка, без модульных рисков

7. Известные дефекты и ограничения

  1. Regex-парсинг ответа show хрупок — если сервер изменит формат вывода, парсинг сломается. Решение: согласование формата с сервером или JSON API.

  2. Polling каждые 3 секунды — избыточная нагрузка при сотнях клиентов. Решение: увеличить интервал или перейти на push.

  3. Фильтры на стороне клиента — если коллекция большая (100K+ объектов), фильтрация через Streams API занимает время. Решение: server-side filtering.

  4. Canvas не использует GPU-ускорение — JavaFX Canvas рендерится на CPU. Для 500+ объектов могут быть просадки FPS. Решение: перейти на Group с Circle или OpenGL через Node.

  5. Нет обработки reconnect — если сервер перезагрузился, client.close() не пересоздаёт подключение. Решение: таймаут + retry logic в ClientProvider.

  6. Session.client — var с nullable — возможно race condition: два вызова execute() в разных корутинах пока client уже закрыт. Решение: synchronized или Mutex.

  7. Нет кэширования locale-строк — при переключении языка каждый bind вызывает get(key) на каждый listener. Для 50+ bind-ов это O(n * m). Решение: кэш MutableMap<String, String> при смене локали.