# Архитектура 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 = FXCollections.observableArrayList() ... } ``` - Kotlin `object` — это потокобезопасный singleton на уровне JVM. - Все компоненты (View, Controller, Canvas, EditDialog) читают и пишут состояние через `Session.*`. - Альтернатива (DI: Guice, Koin, Dagger) неоправданна для такого масштаба — DI имеет смысл, когда есть десятки классов с глубокими графами зависимостей. У нас 4 view, 2 controller, несколько утилит. - `ObservableList` — единственный источник истины (single source of truth). Парсинг ответа сервера (`show`) обновляет именно её, Canvas и TableView подписаны на её изменения. ### 4.3. HumanBeingProperty — observable-обёртка ```kotlin class HumanBeingProperty(humanBeing: HumanBeing) { val name = SimpleStringProperty(humanBeing.name) val coordinateX = SimpleObjectProperty(humanBeing.coordinates.x) val weaponType = SimpleObjectProperty(humanBeing.weaponType) ... } ``` Почему не использовать `HumanBeing` напрямую? - `TableColumn` требует cellValueFactory, который возвращает `ObservableValue`. Поле `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, filters: Map, comparators: List>, ): ObservableList { 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`, а не строим 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 = SimpleObjectProperty(Locale("ru")) private val strings: Map> = mapOf( Locale("ru") to mapOf(...), Locale("nl") to mapOf(...), ... ) fun bind(property: Property, key: String) { property.value = get(key) currentLocale.addListener { _, _, _ -> property.value = get(key) } } } ``` Почему `.properties` файлы не используются: - Требование лабораторной — "i18n должен храниться прямо в коде" (в классе). - Это упрощает сборку: не нужно копировать ресурсы в JAR (нет `processResources`). - Изменение строк не требует сборки проекта — просто правка Kotlin-файла. Почему `ObjectProperty`: - При смене языка нужно перерисовать ВСЕ 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? { val lines = message.lines().filter { it.isNotBlank() } var currentLines = mutableListOf() 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() 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`, где key = имя поля, value = строка поиска, передаётся в `StreamUtils.applyFiltersAndSort`. Сортировка по клику на заголовок: - Клик по headerLabel переключает сортировку для колонки. - `toggleSort(key)` меняет `sortAscending` и создаёт `Comparator`. - Компаратор сравнивает строковые представления значений — это универсально, работает для всех типов (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 + 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` при смене локали.