33 KiB
Архитектура 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():
- Выполняет
Session.client?.execute("show")— блокирующий вызов, обёрнутый вDispatchers.IO. - Парсит текстовый ответ (строка → список HumanBeingProperty).
- Сравнивает ID текущей коллекции с ID из ответа:
toRemove— элементы, которых нет в ответе.toAdd— новые элементы.toUpdate— элементы, чьё name изменилось.
- Все изменения через
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. Известные дефекты и ограничения
-
Regex-парсинг ответа
showхрупок — если сервер изменит формат вывода, парсинг сломается. Решение: согласование формата с сервером или JSON API. -
Polling каждые 3 секунды — избыточная нагрузка при сотнях клиентов. Решение: увеличить интервал или перейти на push.
-
Фильтры на стороне клиента — если коллекция большая (100K+ объектов), фильтрация через Streams API занимает время. Решение: server-side filtering.
-
Canvas не использует GPU-ускорение — JavaFX Canvas рендерится на CPU. Для 500+ объектов могут быть просадки FPS. Решение: перейти на
GroupсCircleили OpenGL черезNode. -
Нет обработки reconnect — если сервер перезагрузился, client.close() не пересоздаёт подключение. Решение: таймаут + retry logic в ClientProvider.
-
Session.client — var с nullable — возможно race condition: два вызова
execute()в разных корутинах пока client уже закрыт. Решение:synchronizedилиMutex. -
Нет кэширования locale-строк — при переключении языка каждый bind вызывает
get(key)на каждый listener. Для 50+ bind-ов это O(n * m). Решение: кэшMutableMap<String, String>при смене локали.