Ah shit. Here we go again.

This commit is contained in:
2026-04-13 01:01:04 +03:00
commit d97bbed00f
53 changed files with 2047 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
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-server')
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.github.microutils:kotlin-logging-jvm:3.0.5'
implementation "io.ktor:ktor-network:3.4.2"
implementation project(':common')
}
kotlin {
jvmToolchain(17)
}
+75
View File
@@ -0,0 +1,75 @@
import collection.CollectionManager
import commands.*
import file.FileManager
import network.NetworkManager
import runner.CommandInvoker
import kotlin.system.exitProcess
fun main(args: Array<String>) {
System.setProperty("slf4j.internal.verbosity", "WARN")
val filePath = if (args.isNotEmpty()) args[0] else "data.csv"
val fileManager = FileManager(filePath)
val manager = CollectionManager()
val invoker = CommandInvoker()
// Загружаем коллекцию из файла
manager.loadFromFile(fileManager.read())
// Регистрируем команды
registerServerCommands(invoker, manager, fileManager)
// Сохранение при завершении (Ctrl+C или kill)
val server = NetworkManager(port = 8080, invoker = invoker)
Runtime.getRuntime().addShutdownHook(Thread {
println("[Server] Сохранение коллекции перед выходом...")
fileManager.write(manager.getAll())
server.stop()
})
// Консоль на сервере
Thread {
val scanner = java.util.Scanner(System.`in`)
println("[Server] Серверные команды: save, exit")
while (scanner.hasNextLine()) {
when (scanner.nextLine().trim().lowercase()) {
"save" -> {
fileManager.write(manager.getAll())
println("[Server] Коллекция сохранена.")
}
"exit" -> {
println("[Server] Завершение работы...")
exitProcess(0)
}
else -> println("[Server] Доступные команды: save, exit")
}
}
}.also { it.isDaemon = true }.start()
server.start()
}
fun registerServerCommands(
invoker: CommandInvoker,
manager: CollectionManager,
fileManager: FileManager
) {
listOf(
HelpCommand(invoker),
InfoCommand(manager),
ShowCommand(manager),
AddCommand(manager),
UpdateCommand(manager),
RemoveByIdCommand(manager),
ClearCommand(manager),
SaveCommand(manager, fileManager), // только на сервере
AddIfMaxCommand(manager),
AddIfMinCommand(manager),
HistoryCommand(invoker),
SumOfMinutesCommand(manager),
MinByNameCommand(manager),
PrintDescendingMinutesCommand(manager),
ExitCommand()
).forEach { invoker.register(it) }
}
@@ -0,0 +1,91 @@
package collection
import models.HumanBeing
import mu.KotlinLogging
import java.time.LocalDate
import java.util.TreeSet
import java.util.UUID
private val logger = KotlinLogging.logger {}
class CollectionManager {
private val collection: TreeSet<HumanBeing> = TreeSet()
val initDate: LocalDate = LocalDate.now()
fun add(humanBeing: HumanBeing): Boolean {
logger.info { "[ADD] Создан элемент ${humanBeing.id}" }
return collection.add(humanBeing)
}
fun update(id: UUID, updated: HumanBeing): Boolean {
val old = getById(id) ?: return false
collection.remove(old)
val replaced = updated.copy(id = old.id, creationDate = old.creationDate)
logger.info { "[UPDATE] Обновлён элемент ${old.id}" }
return collection.add(replaced)
}
fun removeById(id: UUID): Boolean {
val target = getById(id) ?: return false
logger.info { "[REMOVE] Удалён элемент ${target.id}" }
return collection.remove(target)
}
fun clear() {
collection.clear()
logger.info { "[CLEAR] Коллекция очищена" }
}
fun getAll(): TreeSet<HumanBeing> = TreeSet(collection)
fun getById(id: UUID): HumanBeing? =
collection.stream()
.filter { it.id == id }
.findFirst()
.orElse(null)
fun size(): Int = collection.size
fun isEmpty(): Boolean = collection.isEmpty()
fun getMax(): HumanBeing? =
collection.stream()
.max(Comparator.naturalOrder())
.orElse(null)
fun getMin(): HumanBeing? =
collection.stream()
.min(Comparator.naturalOrder())
.orElse(null)
fun sumOfMinutesOfWaiting(): Double =
collection.stream()
.mapToDouble { it.minutesOfWaiting.toDouble() }
.sum()
fun minByName(): HumanBeing? =
collection.stream()
.min(Comparator.comparing { it.name })
.orElse(null)
fun getMinutesOfWaitingDescending(): List<Float> =
collection.stream()
.map { it.minutesOfWaiting }
.sorted(Comparator.reverseOrder())
.toList()
fun loadFromFile(items: List<HumanBeing>) {
collection.clear()
items.stream().forEach { collection.add(it) }
logger.info { "[LOAD] Загружено ${items.size} элементов" }
}
fun getInfo(): String =
"""
|Тип коллекции : ${collection::class.simpleName}
|Тип элементов : ${HumanBeing::class.simpleName}
|Дата инициал. : $initDate
|Кол-во элемен.: ${collection.size}
""".trimMargin()
}
@@ -0,0 +1,17 @@
package commands
import collection.CollectionManager
import models.HumanBeing
class AddCommand(private val manager: CollectionManager) : Command {
override val name = "add"
override val description = "добавить новый элемент в коллекцию"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
humanBeing ?: return "[Ошибка] Объект HumanBeing не передан."
return if (manager.add(humanBeing))
"Элемент добавлен: ${humanBeing.name} [${humanBeing.id}]"
else
"Элемент уже существует в коллекции."
}
}
@@ -0,0 +1,20 @@
package commands
import collection.CollectionManager
import models.HumanBeing
class AddIfMaxCommand(private val manager: CollectionManager) : Command {
override val name = "add_if_max"
override val description = "добавить элемент, если он больше максимального"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
humanBeing ?: return "[Ошибка] Объект HumanBeing не передан."
val max = manager.getMax()
return if (max == null || humanBeing > max) {
manager.add(humanBeing)
"Элемент добавлен (превышает максимум)."
} else {
"Элемент не добавлен: не превышает текущий максимум (${max.name})."
}
}
}
@@ -0,0 +1,20 @@
package commands
import collection.CollectionManager
import models.HumanBeing
class AddIfMinCommand(private val manager: CollectionManager) : Command {
override val name = "add_if_min"
override val description = "добавить элемент, если он меньше минимального"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
humanBeing ?: return "[Ошибка] Объект HumanBeing не передан."
val min = manager.getMin()
return if (min == null || humanBeing < min) {
manager.add(humanBeing)
"Элемент добавлен (меньше минимума)."
} else {
"Элемент не добавлен: не меньше текущего минимума (${min.name})."
}
}
}
@@ -0,0 +1,14 @@
package commands
import collection.CollectionManager
import models.HumanBeing
class ClearCommand(private val manager: CollectionManager) : Command {
override val name = "clear"
override val description = "очистить коллекцию"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
manager.clear()
return "Коллекция очищена."
}
}
@@ -0,0 +1,21 @@
package commands
import models.HumanBeing
/**
* Базовый интерфейс команды.
*
* Теперь execute() возвращает String — результат для отправки клиенту.
* Зависимость от IOManager полностью убрана из команд.
*/
interface Command {
val name: String
val description: String
/**
* @param args строковые аргументы (UUID и т.п.)
* @param humanBeing объект из запроса — только для add/update/add_if_max/add_if_min
* @return строка-результат для клиента
*/
fun execute(args: List<String>, humanBeing: HumanBeing? = null): String
}
@@ -0,0 +1,13 @@
package commands
/**
* Завершает программу без сохранения.
*/
import models.HumanBeing
class ExitCommand() : Command {
override val name = "exit"
override val description = "завершить программу"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String = ""
}
@@ -0,0 +1,15 @@
package commands
import runner.CommandInvoker
import models.HumanBeing
class HelpCommand(private val invoker: CommandInvoker) : Command {
override val name = "help"
override val description = "вывести справку по доступным командам"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String =
invoker.getCommands().values
.sortedBy { it.name }
.joinToString("\n") { " ${it.name.padEnd(40)} — ${it.description}" }
}
@@ -0,0 +1,15 @@
package commands
import runner.CommandInvoker
import models.HumanBeing
class HistoryCommand(private val invoker: CommandInvoker) : Command {
override val name = "history"
override val description = "вывести последние 12 команд"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
val history = invoker.getHistory()
if (history.isEmpty()) return "История пуста."
return history.mapIndexed { i, cmd -> " ${i + 1}. $cmd" }.joinToString("\n")
}
}
@@ -0,0 +1,12 @@
package commands
import collection.CollectionManager
import models.HumanBeing
class InfoCommand(private val manager: CollectionManager) : Command {
override val name = "info"
override val description = "вывести информацию о коллекции"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String =
manager.getInfo()
}
@@ -0,0 +1,15 @@
package commands
import collection.CollectionManager
import models.HumanBeing
/**
* Выводит любой элемент с минимальным значением поля [HumanBeing.name].
*/
class MinByNameCommand(private val manager: CollectionManager) : Command {
override val name = "min_by_name"
override val description = "вывести элемент с минимальным именем"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String =
manager.minByName()?.toString() ?: "Коллекция пуста."
}
@@ -0,0 +1,18 @@
package commands
import collection.CollectionManager
import models.HumanBeing
/**
* Выводит значения поля [models.HumanBeing.minutesOfWaiting] в порядке убывания.
*/
class PrintDescendingMinutesCommand(private val manager: CollectionManager) : Command {
override val name = "print_field_descending_minutes_of_waiting"
override val description = "вывести minutesOfWaiting в порядке убывания"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
val values = manager.getMinutesOfWaitingDescending()
return if (values.isEmpty()) "Коллекция пуста."
else values.joinToString("\n")
}
}
@@ -0,0 +1,23 @@
package commands
import collection.CollectionManager
import java.util.UUID
import models.HumanBeing
class RemoveByIdCommand(private val manager: CollectionManager) : Command {
override val name = "remove_by_id"
override val description = "удалить элемент по id: remove_by_id <uuid>"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
if (args.isEmpty()) return "[Ошибка] Укажите id. Пример: remove_by_id <uuid>"
val id = try {
UUID.fromString(args[0])
} catch (e: IllegalArgumentException) {
return "[Ошибка] Некорректный UUID: '${args[0]}'"
}
return if (manager.removeById(id)) "Элемент удалён."
else "[Ошибка] Элемент с id '$id' не найден."
}
}
@@ -0,0 +1,18 @@
package commands
import collection.CollectionManager
import file.FileManager
import models.HumanBeing
class SaveCommand(
private val manager: CollectionManager,
private val fileManager: FileManager
) : Command {
override val name = "save"
override val description = "[SERVER] сохранить коллекцию в файл"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
return if (fileManager.write(manager.getAll())) "Коллекция сохранена."
else "[Ошибка] Не удалось сохранить коллекцию."
}
}
@@ -0,0 +1,14 @@
package commands
import collection.CollectionManager
import models.HumanBeing
class ShowCommand(private val manager: CollectionManager) : Command {
override val name = "show"
override val description = "вывести все элементы коллекции"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
if (manager.isEmpty()) return "Коллекция пуста."
return manager.getAll().joinToString("\n") { it.toString() }
}
}
@@ -0,0 +1,15 @@
package commands
import collection.CollectionManager
import models.HumanBeing
/**
* Выводит сумму значений поля [models.HumanBeing.minutesOfWaiting].
*/
class SumOfMinutesCommand(private val manager: CollectionManager) : Command {
override val name = "sum_of_minutes_of_waiting"
override val description = "вывести сумму minutesOfWaiting всех элементов"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String =
"Сумма minutesOfWaiting: ${manager.sumOfMinutesOfWaiting()}"
}
@@ -0,0 +1,24 @@
package commands
import collection.CollectionManager
import models.HumanBeing
import java.util.UUID
class UpdateCommand(private val manager: CollectionManager) : Command {
override val name = "update"
override val description = "обновить элемент по id: update <uuid>"
override fun execute(args: List<String>, humanBeing: HumanBeing?): String {
if (args.isEmpty()) return "[Ошибка] Укажите id. Пример: update <uuid>"
humanBeing ?: return "[Ошибка] Объект HumanBeing не передан."
val id = try {
UUID.fromString(args[0])
} catch (e: IllegalArgumentException) {
return "[Ошибка] Некорректный UUID: '${args[0]}'"
}
return if (manager.update(id, humanBeing)) "Элемент обновлён."
else "[Ошибка] Элемент с id '$id' не найден."
}
}
+181
View File
@@ -0,0 +1,181 @@
package file
import models.Car
import models.Coordinates
import models.HumanBeing
import models.WeaponType
import java.io.File
import java.io.FileNotFoundException
import java.io.OutputStreamWriter
import java.time.LocalDate
import java.time.format.DateTimeParseException
import java.util.Scanner
import java.util.UUID
/**
* Менеджер файлового хранилища коллекции.
*
* Читает данные с помощью [Scanner], записывает через [OutputStreamWriter].
* Формат хранения — CSV, одна строка = один объект [HumanBeing].
*
* Порядок полей в CSV:
* `id,name,coordX,coordY,creationDate,realHero,hasToothpick,
* impactSpeed,soundtrackName,minutesOfWaiting,weaponType,carCool`
*
* @property filePath путь к CSV-файлу, полученный из аргумента командной строки
*/
class FileManager(private val filePath: String) {
companion object {
private const val DELIMITER = ","
private const val HEADER =
"id,name,coordX,coordY,creationDate,realHero," +
"hasToothpick,impactSpeed,soundtrackName,minutesOfWaiting,weaponType,carCool"
}
/**
* Читает коллекцию из CSV-файла с помощью [Scanner].
*
* Пропускает заголовок и битые строки (с предупреждением в stderr).
* При отсутствии файла или проблемах с доступом возвращает пустой список.
*
* @return список корректно распарсенных объектов [HumanBeing]
*/
fun read(): List<HumanBeing> {
val file = File(filePath)
if (!file.exists()) {
System.err.println("Файл '$filePath' не найден. Коллекция будет пустой.")
return emptyList()
}
if (!file.canRead()) {
System.err.println("Нет прав на чтение файла '$filePath'. Коллекция будет пустой.")
return emptyList()
}
val result = mutableListOf<HumanBeing>()
var lineNumber = 0
try {
Scanner(file, Charsets.UTF_8).use { scanner ->
while (scanner.hasNextLine()) {
val line = scanner.nextLine().trim()
lineNumber++
// Пропускаем заголовок и пустые строки
if (lineNumber == 1 && line.startsWith("id")) continue
if (line.isBlank()) continue
parseLine(line, lineNumber)
?.let { result.add(it) }
?: System.err.println("Строка $lineNumber пропущена: некорректные данные → '$line'")
}
}
} catch (e: FileNotFoundException) {
System.err.println("Файл не найден во время чтения: ${e.message}")
}
println("Загружено элементов: ${result.size} из файла '$filePath'")
return result
}
/**
* Парсит одну CSV-строку в объект [HumanBeing].
*
* @param line строка CSV
* @param lineNumber номер строки (для сообщений об ошибках)
* @return объект [HumanBeing] или null при ошибке парсинга
*/
private fun parseLine(line: String, lineNumber: Int): HumanBeing? {
return try {
val parts = line.split(DELIMITER, limit = 12)
if (parts.size < 12) {
System.err.println("Строка $lineNumber: ожидалось 12 полей, найдено ${parts.size}")
return null
}
val id = UUID.fromString(parts[0].trim())
val name = parts[1].trim()
val coordX = parts[2].trim().toLong()
val coordY = parts[3].trim().toInt()
val creationDate = LocalDate.parse(parts[4].trim())
val realHero = parts[5].trim().toBoolean()
val hasToothpick = parts[6].trim().toBoolean()
val impactSpeed = parts[7].trim().toDouble()
val soundtrackName = parts[8].trim()
val minutesOfWaiting = parts[9].trim().toFloat()
val weaponType = parts[10].trim().let {
if (it.equals("null", ignoreCase = true) || it.isBlank()) null
else WeaponType.valueOf(it)
}
val carCool = parts[11].trim().toBoolean()
// самая лучшая валидация (100%)
require(name.isNotBlank()) { "name не может быть пустым" }
// от такого может быть диабет
HumanBeing(
id = id, name = name, coordinates = Coordinates(coordX, coordY),
creationDate = creationDate, realHero = realHero, hasToothpick = hasToothpick,
impactSpeed = impactSpeed, soundtrackName = soundtrackName,
minutesOfWaiting = minutesOfWaiting, weaponType = weaponType, car = Car(carCool)
)
} catch (e: IllegalArgumentException) {
System.err.println("Строка $lineNumber — ошибка данных: ${e.message}")
null
} catch (e: DateTimeParseException) {
System.err.println("Строка $lineNumber — неверный формат даты: ${e.message}")
null
} catch (e: Exception) {
System.err.println("Строка $lineNumber — неожиданная ошибка: ${e.message}")
null
}
}
/**
* Записывает коллекцию в CSV-файл с помощью [OutputStreamWriter].
*
* @param items коллекция объектов [HumanBeing] для записи
* @return true если запись прошла успешно
*/
fun write(items: Collection<HumanBeing>): Boolean {
val file = File(filePath)
if (file.exists() && !file.canWrite()) {
System.err.println("Нет прав на запись в файл '$filePath'.")
return false
}
return try {
OutputStreamWriter(file.outputStream(), Charsets.UTF_8).use { writer ->
writer.write(HEADER)
writer.write("\n")
items.forEach { h ->
writer.write(toCSVLine(h))
writer.write("\n")
}
}
println("Коллекция сохранена в '$filePath' (${items.size} элементов).")
true
} catch (e: SecurityException) {
println("Отказано в доступе к файлу '$filePath': ${e.message}")
false
} catch (e: Exception) {
println("Ошибка записи в файл: ${e.message}")
false
}
}
/**
* Сериализует объект [HumanBeing] в строку CSV.
*
* @param h объект для сериализации
* @return строка в формате CSV
*/
private fun toCSVLine(h: HumanBeing): String = listOf(
h.id, h.name, h.coordinates.x, h.coordinates.y, h.creationDate,
h.realHero, h.hasToothpick, h.impactSpeed, h.soundtrackName,
h.minutesOfWaiting, h.weaponType ?: "null", h.car.cool
).joinToString(DELIMITER)
}
@@ -0,0 +1,71 @@
package network
import kotlinx.serialization.SerializationException
import lab6.prog.network.AppJson
import runner.CommandInvoker
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
private const val BUFFER_SIZE = 65507
/**
* UDP-сервер на основе DatagramSocket (датаграммы).
*
* Однопоточный: receive → deserialize → execute → send.
*
* @property port порт прослушивания
* @property invoker инвокер серверных команд
*/
class NetworkManager(
private val port: Int,
private val invoker: CommandInvoker
) {
private val socket = DatagramSocket(port)
fun start() {
println("[Server] Запущен на порту $port. Ожидание клиентов...")
val buf = ByteArray(BUFFER_SIZE)
while (!socket.isClosed) {
try {
// 1. Принять датаграмму
val packet = DatagramPacket(buf, buf.size)
socket.receive(packet)
val json = String(packet.data, 0, packet.length, Charsets.UTF_8)
// 2. Десериализовать запрос
val request = try {
AppJson.decodeFromString<Request>(json)
} catch (e: SerializationException) {
System.err.println("[Server] Некорректный JSON: ${e.message}")
sendResponse(Response(false, "Некорректный формат запроса"), packet.address, packet.port)
continue
}
println("[Server] '${request.commandName}' от ${packet.address}:${packet.port}")
// 3. Выполнить и получить ответ
val response = invoker.execute(request)
// 4. Отправить ответ
sendResponse(response, packet.address, packet.port)
} catch (e: Exception) {
if (!socket.isClosed)
System.err.println("[Server] Ошибка: ${e.message}")
}
}
}
private fun sendResponse(response: Response, address: InetAddress, port: Int) {
val bytes = AppJson.encodeToString(Response.serializer(), response).toByteArray(Charsets.UTF_8)
socket.send(DatagramPacket(bytes, bytes.size, address, port))
}
fun stop() {
println("[Server] Остановка...")
socket.close()
}
}
@@ -0,0 +1,48 @@
package runner
import commands.Command
import network.Request
import network.Response
/**
* Серверный инвокер.
*
* Принимает Request, находит команду, выполняет,
* возвращает Response готовый для отправки клиенту.
*/
class CommandInvoker {
private val serverOnlyCommands = setOf("save")
private val commands = mutableMapOf<String, Command>()
private val history = ArrayDeque<String>()
fun register(command: Command) {
commands[command.name] = command
}
fun execute(request: Request): Response {
if (request.commandName in serverOnlyCommands) {
return Response(false, "Команда '${request.commandName}' недоступна клиенту.")
}
val command = commands[request.commandName]
?: return Response(false, "Неизвестная команда: '${request.commandName}'")
addToHistory(request.commandName)
return try {
val result = command.execute(request.args, request.humanBeing)
Response(true, result)
} catch (e: Exception) {
Response(false, "[Ошибка выполнения] ${e.message}")
}
}
fun getHistory(): List<String> = history.toList()
fun getCommands(): Map<String, Command> = commands.toMap()
private fun addToHistory(name: String) {
if (history.size >= 12) history.removeFirst()
history.addLast(name)
}
}
+44
View File
@@ -0,0 +1,44 @@
<configuration debug="false" statusListenerClass="ch.qos.logback.core.status.NopStatusListener">
<appender name="ALL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/all.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/archived/all-%d{yyyy-MM-dd_HH-mm}.%i.log</fileNamePattern>
<maxFileSize>5MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>100MB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/errors.log</file>
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>WARN</level>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/archived/errors-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>2MB</maxFileSize>
<maxHistory>10</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="ALL_FILE" />
<appender-ref ref="ERROR_FILE" />
</root>
</configuration>