Ah shit. Here we go again.
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
plugins {
|
||||
id 'org.jetbrains.kotlin.jvm' version '2.2.0'
|
||||
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.0'
|
||||
id 'com.gradleup.shadow' version '9.3.1'
|
||||
id 'org.jetbrains.dokka' version '2.2.0'
|
||||
}
|
||||
|
||||
group = 'lab6.prog'
|
||||
version = '1.0'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
shadowJar {
|
||||
archiveBaseName.set('Lab6-client')
|
||||
archiveVersion.set('1.0')
|
||||
manifest {
|
||||
attributes 'Main-Class': 'MainKt'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation 'org.jetbrains.kotlin:kotlin-test'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0'
|
||||
implementation "io.ktor:ktor-network:3.4.2"
|
||||
implementation project(':common')
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import network.NetworkManager
|
||||
import input.HumanBeingBuilder
|
||||
import input.IOManager
|
||||
import network.Request
|
||||
|
||||
|
||||
// BEGIN ---- Move it to CommandValidator
|
||||
|
||||
// Команды, которые требуют ввода HumanBeing
|
||||
private val HUMAN_BEING_COMMANDS = setOf("add", "add_if_max", "add_if_min")
|
||||
|
||||
// Команды с аргументом (UUID) в строке команды
|
||||
private val ARG_COMMANDS = setOf("update", "remove_by_id", "execute_script")
|
||||
|
||||
// Все команды, доступные клиенту (save — только серверная)
|
||||
private val KNOWN_COMMANDS = setOf(
|
||||
"help", "info", "show", "add", "update",
|
||||
"remove_by_id", "clear", "execute_script",
|
||||
"add_if_max", "add_if_min", "history",
|
||||
"sum_of_minutes_of_waiting", "min_by_name",
|
||||
"print_field_descending_minutes_of_waiting",
|
||||
"exit"
|
||||
)
|
||||
|
||||
// END ---- Move it to CommandValidator
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
val host = if (args.isNotEmpty()) args[0] else "localhost"
|
||||
val port = if (args.size >= 2) args[1].toIntOrNull() ?: 8080 else 8080
|
||||
|
||||
val io = IOManager()
|
||||
val client = NetworkManager(host, port)
|
||||
|
||||
io.print("""
|
||||
|╔══════════════════════════════════════╗
|
||||
|║ Lab6 Client — подключение к $host:$port
|
||||
|╚══════════════════════════════════════╝
|
||||
|Введите 'help' для справки.
|
||||
""".trimMargin())
|
||||
|
||||
// Shutdown hook — закрыть канал при выходе
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
client.close()
|
||||
})
|
||||
|
||||
runClientRepl(io, client)
|
||||
}
|
||||
|
||||
fun runClientRepl(io: IOManager, client: NetworkManager) {
|
||||
while (true) {
|
||||
val line = io.readLine(" ☭ ") ?: break
|
||||
if (line.isBlank()) continue
|
||||
|
||||
val parts = line.trim().split(" ")
|
||||
val commandName = parts[0].lowercase()
|
||||
val args = parts.drop(1)
|
||||
|
||||
// Move to CommandValidate
|
||||
if (commandName !in KNOWN_COMMANDS) {
|
||||
io.print("Неизвестная команда: '$commandName'. Введите 'help' для справки.")
|
||||
continue
|
||||
}
|
||||
|
||||
if (commandName == "exit") {
|
||||
io.print("До свидания!")
|
||||
break
|
||||
}
|
||||
|
||||
// In the event of using fucking script (don't)
|
||||
if (commandName == "execute_script") {
|
||||
if (args.isEmpty()) {
|
||||
io.print("[Ошибка] Укажите путь к файлу. Пример: execute_script script.txt")
|
||||
continue
|
||||
}
|
||||
io.addScriptScanner(args[0])
|
||||
continue
|
||||
}
|
||||
|
||||
// MOVE TO FUCKING VALIDATE
|
||||
// 4. Для update — нужен UUID аргумент ДО ввода HumanBeing
|
||||
if (commandName == "update" && args.isEmpty()) {
|
||||
io.print("[Ошибка] Укажите id элемента. Пример: update <uuid>")
|
||||
continue
|
||||
}
|
||||
|
||||
// 5. Строим HumanBeing если нужно
|
||||
val humanBeing = if (commandName in HUMAN_BEING_COMMANDS ||
|
||||
commandName == "update") {
|
||||
try {
|
||||
HumanBeingBuilder(io).build()
|
||||
} catch (e: IllegalStateException) {
|
||||
io.print("[Ошибка] Ввод прерван: ${e.message}")
|
||||
continue
|
||||
}
|
||||
} else null
|
||||
|
||||
// 6. Формируем запрос и отправляем
|
||||
val request = Request(
|
||||
commandName = commandName,
|
||||
args = args,
|
||||
humanBeing = humanBeing
|
||||
)
|
||||
|
||||
val response = client.sendRequest(request)
|
||||
|
||||
// 7. Обрабатываем ответ
|
||||
if (response == null) {
|
||||
io.print("[!] Сервер недоступен. Попробуйте позже.")
|
||||
} else {
|
||||
io.print(response.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package input
|
||||
|
||||
import models.Car
|
||||
import models.Coordinates
|
||||
import models.HumanBeing
|
||||
import models.WeaponType
|
||||
|
||||
/**
|
||||
* Интерактивный построитель объекта [HumanBeing].
|
||||
*
|
||||
* Читает поля по одному из [inputManager], показывает приглашения,
|
||||
* валидирует ввод и повторяет запрос при ошибке.
|
||||
*
|
||||
* Поля [HumanBeing.id] и [HumanBeing.creationDate] генерируются
|
||||
* автоматически и не запрашиваются у пользователя.
|
||||
*
|
||||
* @property inputManager источник ввода
|
||||
*/
|
||||
class HumanBeingBuilder(private val inputManager: IOManager) {
|
||||
|
||||
/**
|
||||
* Запускает интерактивный ввод всех полей и возвращает готовый [HumanBeing].
|
||||
*
|
||||
* @throws IllegalStateException если EOF
|
||||
*/
|
||||
fun build(): HumanBeing {
|
||||
val name = readNonBlankString("Введите имя:")
|
||||
val coordinates = readCoordinates()
|
||||
val realHero = readBoolean("Герой? (true/false):")
|
||||
val hasToothpick = readBoolean("Есть зубочистка? (true/false):")
|
||||
val impactSpeed = readDouble("Введите скорость удара (impactSpeed):")
|
||||
val soundtrackName = readNonBlankString("Введите название саундтрека:")
|
||||
val minutesOfWaiting = readFloat("Введите минуты ожидания (minutesOfWaiting):")
|
||||
val weaponType = readNullableWeaponType()
|
||||
val car = readCar()
|
||||
|
||||
return HumanBeing(
|
||||
name = name, coordinates = coordinates, realHero = realHero,
|
||||
hasToothpick = hasToothpick, impactSpeed = impactSpeed,
|
||||
soundtrackName = soundtrackName, minutesOfWaiting = minutesOfWaiting,
|
||||
weaponType = weaponType, car = car
|
||||
)
|
||||
}
|
||||
|
||||
private fun readCoordinates(): Coordinates {
|
||||
val x = readLong("Введите координату X (тип Long):")
|
||||
val y = readInt("Введите координату Y (тип Int):")
|
||||
return Coordinates(x, y)
|
||||
}
|
||||
|
||||
private fun readCar(): Car {
|
||||
val cool = readBoolean("Крутой автомобиль ? (true/false):")
|
||||
return Car(cool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает непустую строку. Повторяет запрос при пустом вводе.
|
||||
*/
|
||||
private fun readNonBlankString(prompt: String): String {
|
||||
while (true) {
|
||||
val input = inputManager.readLine(" > $prompt")
|
||||
?: throw IllegalStateException("EOF")
|
||||
if (input.isNotBlank()) return input.trim()
|
||||
inputManager.print("[Ошибка] Строка не может быть пустой.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает Long. Повторяет запрос при некорректном вводе.
|
||||
*/
|
||||
private fun readLong(prompt: String): Long {
|
||||
while (true) {
|
||||
val input = inputManager.readLine(" > $prompt")
|
||||
?: throw IllegalStateException("EOF")
|
||||
try {
|
||||
return input.trim().toLong()
|
||||
} catch (e: NumberFormatException) {
|
||||
inputManager.print("[Ошибка] Ожидается целое число (Long). Попробуйте снова.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает Int. Повторяет запрос при некорректном вводе.
|
||||
*/
|
||||
private fun readInt(prompt: String): Int {
|
||||
while (true) {
|
||||
val input = inputManager.readLine(" > $prompt")
|
||||
?: throw IllegalStateException("EOF")
|
||||
try {
|
||||
return input.trim().toInt()
|
||||
} catch (e: NumberFormatException) {
|
||||
inputManager.print("[Ошибка] Ожидается целое число (Int). Попробуйте снова.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает Double. Повторяет запрос при некорректном вводе.
|
||||
*/
|
||||
private fun readDouble(prompt: String): Double {
|
||||
while (true) {
|
||||
val input = inputManager.readLine(" > $prompt")
|
||||
?: throw IllegalStateException("EOF")
|
||||
try {
|
||||
return input.trim().toDouble()
|
||||
} catch (e: NumberFormatException) {
|
||||
inputManager.print("[Ошибка] Ожидается число с плавающей точкой (Double). Попробуйте снова.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает Float. Повторяет запрос при некорректном вводе.
|
||||
*/
|
||||
private fun readFloat(prompt: String): Float {
|
||||
while (true) {
|
||||
val input = inputManager.readLine(" > $prompt")
|
||||
?: throw IllegalStateException("EOF")
|
||||
try {
|
||||
return input.trim().toFloat()
|
||||
} catch (e: NumberFormatException) {
|
||||
inputManager.print("[Ошибка] Ожидается число (Float). Попробуйте снова.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает Boolean (true/false). Повторяет запрос при некорректном вводе.
|
||||
*/
|
||||
private fun readBoolean(prompt: String): Boolean {
|
||||
while (true) {
|
||||
val input = inputManager.readLine(" > $prompt")
|
||||
?: throw IllegalStateException("EOF")
|
||||
when (input.trim().lowercase()) {
|
||||
"true" -> return true
|
||||
"false" -> return false
|
||||
else -> inputManager.print("[Ошибка] Ожидается 'true' или 'false'. Попробуйте снова.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает [WeaponType] или null (пустая строка).
|
||||
* Перед вводом выводит список допустимых констант.
|
||||
*/
|
||||
private fun readNullableWeaponType(): WeaponType? {
|
||||
val constants = WeaponType.entries.joinToString(", ")
|
||||
while (true) {
|
||||
val input = inputManager.readLine(" > Введите тип оружия (доступные: $constants) или оставьте пустым для null:")
|
||||
?: throw IllegalStateException("EOF")
|
||||
if (input.isBlank()) return null
|
||||
try {
|
||||
return WeaponType.valueOf(input.trim().uppercase())
|
||||
} catch (e: IllegalArgumentException) {
|
||||
inputManager.print("[Ошибка] Неизвестный тип оружия '$input'. Допустимые значения: $constants")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выводит приглашение к вводу только в интерактивном режиме.
|
||||
* В режиме скрипта приглашения не выводятся.
|
||||
*/
|
||||
private fun printPrompt(message: String) {
|
||||
if (inputManager.isInteractive) inputManager.print(" > $message ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package input
|
||||
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.util.ArrayDeque
|
||||
import java.util.Scanner
|
||||
|
||||
/**
|
||||
* Реализация [input.IOManager] для чтения потока ввода/файла.
|
||||
* Поддерживает корректную обработку Ctrl+C и Ctrl+D.
|
||||
*/
|
||||
class IOManager() {
|
||||
val isInteractive = true
|
||||
val inputQueue = ArrayDeque<Scanner>()
|
||||
val fileHistory = ArrayDeque<String>()
|
||||
|
||||
init {
|
||||
inputQueue.push(Scanner(System.`in`))
|
||||
}
|
||||
|
||||
fun addScriptScanner(filePath: String) {
|
||||
if (fileHistory.contains(filePath)) {
|
||||
printErrConsole("Скрипт уже был выполнен: $filePath")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
fileHistory.push(filePath)
|
||||
inputQueue.push(Scanner(File(filePath), Charsets.UTF_8))
|
||||
} catch (e: FileNotFoundException) {
|
||||
printErrConsole("Файл не найден: $filePath")
|
||||
}
|
||||
}
|
||||
|
||||
// сделать основной Scanner
|
||||
// при инициализации стандартный сканер добавляется
|
||||
// стек input, один (console) открыт постоянно
|
||||
// если файл - в стек кидается Scanner
|
||||
|
||||
/**
|
||||
* Читает строку с промптом ☭.
|
||||
*/
|
||||
fun readLine(prompt: String): String? {
|
||||
while (inputQueue.isNotEmpty()) {
|
||||
if (inputQueue.size == 1) kotlin.io.print(prompt)
|
||||
val topInDeque = inputQueue.peek()
|
||||
if (topInDeque.hasNextLine()) {
|
||||
|
||||
return topInDeque.nextLine().toString()
|
||||
}
|
||||
if (inputQueue.size > 1) {
|
||||
topInDeque.close()
|
||||
inputQueue.pop()
|
||||
fileHistory.pop()
|
||||
|
||||
// snap back to reality
|
||||
continue
|
||||
}
|
||||
inputQueue.pop()
|
||||
print("Ввод закончен.")
|
||||
break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun print(text: String) {
|
||||
println(text)
|
||||
}
|
||||
|
||||
fun printErrConsole(text: String) {
|
||||
System.err.println(text)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package network
|
||||
|
||||
import kotlinx.serialization.SerializationException
|
||||
import lab6.prog.network.AppJson
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.channels.DatagramChannel
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* UDP-клиент на основе DatagramChannel в неблокирующем режиме.
|
||||
*
|
||||
* При недоступности сервера уведомляет пользователя и повторяет
|
||||
* попытку каждые [retryDelayMS] мс, не более [maxRetries] раз.
|
||||
*
|
||||
* @property host адрес сервера
|
||||
* @property port порт сервера
|
||||
*/
|
||||
class NetworkManager(
|
||||
private val host: String,
|
||||
private val port: Int,
|
||||
private val bufferSize: Int = 65507,
|
||||
private val timeoutMS: Long = 3000,
|
||||
private val retryDelayMS: Long = 5000,
|
||||
private val maxRetries: Int = 3
|
||||
) {
|
||||
private val channel: DatagramChannel = DatagramChannel.open().also {
|
||||
it.configureBlocking(false)
|
||||
it.connect(InetSocketAddress(host, port))
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет [Request] и возвращает [Response].
|
||||
* При недоступности сервера возвращает null.
|
||||
*/
|
||||
fun sendRequest(request: Request): Response? {
|
||||
val requestBytes = AppJson.encodeToString(Request.serializer(), request).toByteArray(Charsets.UTF_8)
|
||||
|
||||
repeat(maxRetries) { attempt ->
|
||||
|
||||
channel.write(ByteBuffer.wrap(requestBytes))
|
||||
|
||||
val response = waitForResponse()
|
||||
if (response != null) return response
|
||||
|
||||
println(
|
||||
"[Client] Сервер не отвечает " +
|
||||
"(попытка ${attempt + 1}/$maxRetries). " +
|
||||
"Повтор через ${retryDelayMS / 1000} сек..."
|
||||
)
|
||||
Thread.sleep(retryDelayMS)
|
||||
}
|
||||
|
||||
println("[Client] Сервер недоступен. Команда не выполнена.")
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Опрашивает канал до [timeoutMS] мс, возвращает ответ или null.
|
||||
*/
|
||||
// catch java.net.PortUnreachableException if server is no more reachable
|
||||
private fun waitForResponse(): Response? {
|
||||
val buf = ByteBuffer.allocate(bufferSize)
|
||||
val deadline = System.currentTimeMillis() + timeoutMS
|
||||
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
buf.clear()
|
||||
if (channel.read(buf) > 0) {
|
||||
buf.flip()
|
||||
return try {
|
||||
AppJson.decodeFromString<Response>(Charsets.UTF_8.decode(buf).toString())
|
||||
} catch (e: SerializationException) {
|
||||
System.err.println("[Client] Некорректный ответ сервера: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
Thread.sleep(50)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun close() = channel.close()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package runner
|
||||
|
||||
class CommandValidator {
|
||||
}
|
||||
Reference in New Issue
Block a user