Skip to content

TeodorDichev/code-check

Repository files navigation

CodeCheck

CodeCheck е stateless (почти) клиент-сървър приложение за управление на образователни курсове и проверка на софтуерни задачи. Проектът е вдъхновен от платформата CodePost и предоставя среда за качване, преглед и оценяване на студентски решения.

Архитектура на системата

Приложението е изградено на принципа Client-Server Architecture чрез TCP сокети.

  • Сървър: Обработва заявките, управлява сесиите и персистира данните във файловата система.

    • Освен всичко това сървърът надгражда стандартната NIO имплементация като той използва и pool от нишки, които да извършват задачите. Идеята е че така освобождаваме селектора.
    • Не само това но и сновната нишка на селектора работи като Producer. Тя отговаря единствено за приемане на нови връзки и четене на суровите данни от сокетите. Вместо да блокира селектора с тежка бизнес логика, тя „произвежда“ задачи и ги подава към опашка.
    • Пул от работни нишки (Worker Threads) действа като Consumer. Тези нишки изземват задачите, десериализират JSON заявките, изпълняват командите (включително тежки операции като компилация) и връщат отговор към клиента. Това позволява на сървъра да обслужва стотици конкурентни заявки, без да „замръзва“ входно-изходния механизъм.
  • Клиент: Конзолен интерфейс (CLI), чрез който потребителите взаимодействат със системата.

    • CLI: Конзолен интерфейс, който десериализира JSON структурите и ги трансформира в Human-Readable Format.
    • Smart Rendering: Клиентът интелигентно обработва данни – визуализиране на сорс код с правилно форматиране на нов ред.
  • Комуникация: Обменът на данни се осъществява чрез JSON формат, сериализиран и десериализиран с библиотеката Google Gson.

    • Проектът е базиран на модела за разделение на отговорностите, като сървърът функционира като универсално API, а клиентът отговаря за презентацията. Сървърът е проектиран да бъде независим от крайния интерфейс (UI agnostic). Вместо готов текст, той изпраща сурови структурирани данни в JSON формат. Това прави бекенда мащабируем и готов за интеграция с уеб или мобилни приложения без промяна в логиката. Клиентът поема ролята на презентационен слой. Той десериализира JSON обектите и ги трансформира във формат, удобен за четене от потребителя (Human-Readable Format).

Технологичен стек

  • Език: Java 25
  • Библиотека за JSON: Google Gson
  • Тестване: JUnit 6
  • Персистиране: Файлова система (JSON файлове за метаданни и .java файлове за решенията)

Функционалности

Потребители и Сесии

  • Регистрация и Вход: със сигурна автентикация

Курсове и Задачи

  • Създаване на курс: генерира се с уникален код за достъп. Този, който го е генерирал се задава като администратор.
  • Добавяне на оценяващи: от присъединилите се към курса, всеки от тях, който е creator може да прави други участници администратори
  • Записване: всеки може да се присъединява към курсове ако знае кода.
  • Задания: създаване на задачи с конкретни крайни срокове само от администратор на курс.

Управление на предадените решения

  • Качване: участниците качват своите решения като .java файлове.
  • Преглед: преглед на списък с файлове и съдържанието на всеки файл директно през клиента.
  • Тестване: оценяващите имат възможност да проверят дали дадените файлове се компилирират

Обратна връзка и оценяване

  • Коментари: добавяне на коментари към конкретни файлове от решението от оценяващите.
  • Оценяване: присвояване на оценки и обща обратна връзка за всяко предадено изпълнение.
  • Преглед на оценка: преглед на дадената обратна връзка

Управление на данните

Repositories and storage

Проектът използва файлова система за постоянно съхранение на информацията, организирана в директория data/. Системата е проектирана със stateless архитектура на ниво хранилища (repositories), което означава, че данните не се кешират в паметта на сървъра в масиви, а се извличат и записват директно от/на диска при всяка заявка. Този подход осигурява "Single Source of Truth" (единствен източник на истината), ниска консумация на RAM и гарантира, че състоянието на системата е консистентно дори при внезапно прекъсване на работата на сървъра.

Структура на директорията

Данните са разделени на логически поддиректории, като всеки обект се съхранява в собствен JSON файл, именуван спрямо неговото уникално ID:

  • data/users/: Индивидуални JSON файлове за всеки потребител (напр. ivan123.json). Съдържат роли, хеширани пароли и списъци с курсове.
  • data/courses/: Метаданни за курсовете – администратори, списък със записани студенти и референции към задачите.
  • data/assignments/: Информация за задачите – заглавия, описания, начални и крайни срокове.
  • data/submissions/: Метаданни за предадените решения – автор, принадлежност към задача, статус на оценяване, оценки и коментари.
  • data/uploads/<course>/<assignment>/<user>: Самите решения
  • logs/: Съдържа exceptions.log и други системни журнали за одит и проследяване на грешки.

Архитектурни решения и предимства

1. Модел "Файл за всеки обект" (File-per-Entity)

За разлика от съхранението в един масивен JSON файл, раздробяването на данните на индивидуални файлове позволява:

  • Висока производителност: Операциите по четене и запис засягат само конкретния обект (напр. един потребител), вместо да презаписват цялата база данни.
  • Изолация: Потенциална грешка при запис на едно решение не застрашава целостта на останалите данни в системата.
  • Мащабируемост: Системата може да управлява хиляди обекти, без да натоварва оперативната памет с излишни масиви.

2. Атомарност и нишкова безопасност

Атомарността и нишковата безопасност в системата се гарантират на ниво команда (Command layer), а не на ниво отделни repository методи. Всяка команда представлява една завършена бизнес операция (напр. коментиране, оценяване, предаване на решение) и обхваща целия цикъл четене → валидация → промяна → запис като неделима, атомарна единица.

Repository слоят умишлено е проектиран да бъде stateless и максимално прост, а Storage слоят предоставя елементарни I/O операции за четене и запис от файловата система, без вътрешна синхронизация. Това предотвратява фалшиво усещане за нишкова безопасност, при което отделни синхронизирани методи не могат да гарантират коректност на по-сложни операции, включващи няколко последователни извиквания.

Синхронизацията се реализира в командите чрез фино-гранулирано заключване по идентификатор на домейн обекта (напр. submission, course, assignment). По този начин:

  • се гарантира, че конкурентни заявки, работещи върху един и същ обект, няма да доведат до race condition;
  • се запазва възможността за паралелно изпълнение на несвързани операции;
  • се постига атомарност на цялата бизнес операция, а не само на отделни файлови записи.

Този подход е особено подходящ в комбинация с използването на virtual threads, тъй като блокиращите операции не възпрепятстват мащабируемостта на сървъра.

3. Ефективно управление на паметта

Системата е проектирана с фокус върху минимална консумация на оперативна памет и ясно дефиниран източник на истина (Single Source of Truth). Repository слоят не поддържа вътрешни кешове или дългоживеещи структури от данни. Вместо това, при всяка команда необходимите обекти се:

  • зареждат от файловата система;
  • десериализират от JSON чрез библиотеката Gson;
  • използват в рамките на конкретната операция;
  • освобождават от паметта веднага след приключването ѝ.

Този модел предотвратява:

  • прекомерно натоварване на RAM при големи обеми данни;
  • проблеми със синхронизацията между кеширани обекти;
  • несъответствия между състоянието в паметта и състоянието на диска.

При големи масиви от данни (например стотици мегабайти JSON файлове), зареждането им изцяло в паметта би довело до значително по-висока консумация на ресурси поради обектното представяне в Java. Директният достъп до диска осигурява предвидимост и стабилност на ресурсите, което е особено важно за сървърни приложения.

Архитектурата позволява бъдещи оптимизации като логическо сегментиране на файловете (напр. по хеш на ID или по начален символ), без промяна в бизнес логиката. С други думи sharding и разделяне по папки според началния символ на файла така че да могат да се търсят с по ефективни алгоритми и да зареждат по-бързо.

4. Фино-гранулирано заключване чрез Lock Registry

За постигане на висока пропускателна способност и коректност при конкурентен достъп, системата използва заключване по ключ (per-entity locking), реализирано чрез централен Lock Registry. Вместо да се блокира цялото repository или да се използват глобални синхронизирани секции, за всеки домейн обект се асоциира отделен ключ, идентифициран чрез неговото уникално ID.

Този подход заменя използването на String.intern(), както бях тръгнал да го правя и предоставя следните предимства:

  • избягва използването на JVM String Pool като механизъм за синхронизация;
  • предотвратява неконтролируемо задържане на обекти в паметта;
  • осигурява ясен lifecycle на заключванията;
  • позволява прецизен контрол върху конкурентния достъп.

Заключването се извършва в Command слоя, като всяка команда придобива заключване за конкретния обект преди модификация и го освобождава след завършване на операцията. По този начин администратор, работещ върху обект от „Курс А“, може паралелно да извършва операции с друг администратор върху „Курс Б“, без взаимно блокиране.

5. CodeCheckContext

За управление на зависимостите проектът използва централизиран CodeCheckContext, реализиран чрез Builder design patter.

  • Централизирано управление: Контекстът съхранява инстанциите на хранилища (repositories) и услуги (services).
  • Гъвкавост: Позволява лесна подмяна на компоненти (напр. различни Storage стратегии) и гарантира, че всяка команда има достъп до необходимите ресурси чрез единна точка.

Управление на сесиите и State Handling

Системата използва хибриден подход за управление на състоянието, комбинирайки stateless repositories с "stateful" управление на активните връзки чрез SelectionKey прикачвания.

Интелигентно управление на сокетите

Всеки клиентски канал (SocketChannel) има асоцииран обект ConnectionState. Това е "паметта" на сървъра за конкретната връзка, която позволява:

  • Разделяне на протоколите: Поддържа два отделни буфера — commandBuffer (за текстови JSON команди) и fileBuffer (за бинарни данни). Това предотвратява смесването на метаданни и файлово съдържание.
  • Upload State Machine: Управлява прехода между трите фази на качване:
    1. Validation Phase (awaitingFileUpload): Командата е валидирана, но файлът още не е започнал да пристига.
    2. Transfer Phase (receivingFile): Сървърът е отворил FileChannel и активно записва байтове директно на диска.
    3. Completion Phase: Проверка за интегритет чрез expectedFileSize спрямо bytesReceived.
  • Thread-Safety: Пълна синхронизация чрез вътрешен lock обект, което позволява на нишките от пула безопасно да модифицират състоянието на връзката.

Баланс между скорост и консистентност

Класът DefaultSessionStore служи като кеш слой за активните потребителски сесии.

  • Concurrent Mapping: Използва ConcurrentHashMap за асоцииране на SocketChannel с обекта User.
  • Performance vs. Truth: Макар и приложението да е проектирано като stateless, кеширането на потребителите в паметта с пестява интензивното четене от диска при всяка проверка за автентикация, без да нарушава "Single Source of Truth" модела за бизнес данните (курсове, оценки, решения).

Обработка на задачи чрез UploadTask

За да не се блокира Selector нишката при работа с големи файлове, сървърът капсулира операциите в UploadTask.

  • Record-based Tasking: Използва Java record за имутабилно предаване на контекста (канал, състояние и команда) към Worker нишките.
  • Async Processing: Това позволява файлът да се обработва, индексира и валидира асинхронно, докато сървърът продължава да приема нови команди от същия или други клиенти.

Механизъм на качване на файлове

Процесът на качване в CodeCheck е проектиран да бъде максимално устойчив:

  1. Command Send: Клиентът изпраща submit-assignment с метаданни за пътя и името.
  2. Server Validation: Сървърът проверява крайни срокове, членство в курс и права. Ако всичко е наред, маркира връзката като awaitingFileUpload.
  3. Binary Stream: Клиентът започва да излива байтовете на файла.
  4. Direct Write: Сървърът чете от сокета и пише директно в currentFileChannel, като следи прогреса в реално време през ConnectionState.
  5. Auto-Cleanup: След приключване или при грешка, clearUpload() гарантира, че файловите дескриптори се затварят правилно, предотвратявайки "resource leaks". Ако грешката се случи след като байтовете са изпратени конекцията се затваря, защото връзката с клиента вече е замърсена от изпратените пакети.

Тестване

Проектът включва Unit тестове за:

  • Валидация на потребителски вход.
  • Логика за проверка на крайни срокове.
  • Сериализация/Десериализация на JSON обекти.
  • Покритие на кода: 89% classes, 78% lines covered
  • Оставям линкове към използвани методи/функции, които не са учени в курса:
  • Никога не бях писал толкова много тестове ;) ☕

Команден интерфейс

Общи

  • register username password

  • login username password

  • logout

  • help

  • exit

  • create-course <course-name>

  • list-created-courses

  • list-joined-courses

  • join-course <course-name> <access-code>

  • list-assignments <course-name>

  • view-assignment <course-name> <assignment-name>

За не-оценяващи

  • submit-assignment <course-name> <assignment-name> <files-path> - само ако не си оценяващ в курса
  • view-my-submission <course-name> <assignment-name> - само ако не си оценяващ в курса {оценка/коментари}

За оценяващи

  • upgrade-participant <course-name> <participant-username> - само ако си оценяващ в курса
  • list-course-participants <course-name> - само ако си оценяващ в курса
  • create-assignment <course-name> <assignment-name> <description> <deadline> - само ако си оценяващ в курса
  • view-submissions <course-name> <assignment-name> - само си оценяващ в курса
  • view-student-submission <course-name> <assignment-name> <participant-username> - само си оценяващ в курса {дата на предаване/файлове}
  • view-submission-file <course-name> <assignment-name> <participant-username> <file-name> - само ако си оценяващ в курса
  • test-submission <course-name> <assignment-name> <participant-username> - само ако си оценяващ в курса
  • test-submissions <course-name> <assignment-name> - само ако си оценяващ в курса
  • grade-submission <course-name> <assignment-name> <participant-username> <general-grade> <general-comment> - само ако си оценяващ в курса
  • comment-submission-file <course-name> <assignment-name> <student-username> <file-name> <comment> - само ако си оценяващ в курса

Бележки

  • 1 потребител не може да има 2 курса с еднакво име, за да може да се избира по име
  • 1 курс не може да има 2 задачи с еднакво име, за да може да се избира по име
  • Щеше да е по-лесно за мен да се търси по id, но те не са удобни за писане, затова е по име
  • При имена, на който и да е обект символът '_' е забранен, защото го ползвам като разделител в имената на файловете
  • За доста от json би било полезно да не се връщат директно entities, а да се дефинират модели/DTO, с обобщена информация за клиента, но за това не ми стигна времето
  • Реално не знам колко е ок да ползвам FileChannel понеже неговата най-голяма сила е transferTo (има го в линковете долу), а аз няма как да го ползвам, понеже моето API не е само за файлове. Понеже обаче четох доста за него все пак го оставих да го ползвам за запис на файлове. Той е лесен начин за работа с byteBuffers, с които стандартните FileOutputStream или FileWriter не работят.

Проектът е разработен като курсова работа, фокусирана върху мрежовото програмиране и управлението на файлови потоци в Java. *Disclaimer: Части от readme файла са редактирани от AI, за да звучат по формални и за да са на български

Забавно четиво 🐞

  1. Започнах като зареждах всичко от папките в Map-ове в repositories - сетих се че може да е проблем Определено ако имаме милиони файлове сегашната система ще е бавна, но ще работи, няма да крашне, докато ако зареждаме всичко в паметта, тя въобще няма да работи.
  2. После направих винаги да се четат като се зареждат файловете. Това искаше Storage и Repository synchronization на методи
  3. После се сетих че това ще е бавно защото може да пишем в 2 отделни файла и пак нишките ще се изчакват
  4. После се сетих че те имат уникални id-та и мога да ползвам string pool-a за да ги заключвам
  5. После прочетох няколко статии колко е лошо да ползваш .intern и да lock-ваш по това
  6. Запознах се с reentrant locks и как да работя със stateless API и зареждане на ресурси, понеже се оказа че зареждайки един и същи json 2 пъти, това са 2 различни инстанции
  7. После се отказах да го ползвам и направих map-a с нормални обекти. До тук добре! Сега дойде най-лошото!!!
  8. Отне ми 2 дни да разгадая как да прехвърлям файлове по мрежата:
    • 8.1. Не исках просто да ги encode-вам и да ги пускам, защото това увеличава размера им
    • 8.2. Също така как се пренасят файлове в неблокиращ режим???
    • 8.3. След много четене, гледане на клипове и каране с ChatGpt успях да създам нещо работещо
    • 8.4. Не е перфектно но работи, когато клиент пренася файлове по мрежата това не блокира комуникацията на другите клиенти
    • 8.5. Стейтовете работят горе-долу окей.
    • 8.6. Изпитвах сериозни трудности с валидацията на данни, но сегашното решение с 2 отговора е задоволително
    • 8.7. Научих много за FileChannels, BlockingQueue и т.н.
  9. После, когато дойде време да се пробвам да компилирам файлове програматично забравих за 1000Mb файл, който бях създал със скрипт за тестване на преноса. Разбира се като го компилирах понеже той беше пълен с null конзолата се напълни с толкова много грешки, толкова бързо че компютърът ми крашна... За щастие командата ми беше ясно благодарение на курса по devops.

Полезни връзки:

About

A custom project for the Modern Java Technologies course at @fmi

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages