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

566 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура 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
```kotlin
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-обёртка
```kotlin
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
```kotlin
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 + команды
```kotlin
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
```kotlin
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
```kotlin
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 — модальное окно
```kotlin
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
```kotlin
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`
```kotlin
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
```kotlin
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
```kotlin
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
```kotlin
// 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<Locale> + 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>` при смене локали.