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файлове. - Преглед: преглед на списък с файлове и съдържанието на всеки файл директно през клиента.
- Тестване: оценяващите имат възможност да проверят дали дадените файлове се компилирират
- Коментари: добавяне на коментари към конкретни файлове от решението от оценяващите.
- Оценяване: присвояване на оценки и обща обратна връзка за всяко предадено изпълнение.
- Преглед на оценка: преглед на дадената обратна връзка
Проектът използва файлова система за постоянно съхранение на информацията, организирана в директория 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и други системни журнали за одит и проследяване на грешки.
За разлика от съхранението в един масивен JSON файл, раздробяването на данните на индивидуални файлове позволява:
- Висока производителност: Операциите по четене и запис засягат само конкретния обект (напр. един потребител), вместо да презаписват цялата база данни.
- Изолация: Потенциална грешка при запис на едно решение не застрашава целостта на останалите данни в системата.
- Мащабируемост: Системата може да управлява хиляди обекти, без да натоварва оперативната памет с излишни масиви.
Атомарността и нишковата безопасност в системата се гарантират на ниво команда (Command layer), а не на ниво отделни repository методи. Всяка команда представлява една завършена бизнес операция (напр. коментиране, оценяване, предаване на решение) и обхваща целия цикъл четене → валидация → промяна → запис като неделима, атомарна единица.
Repository слоят умишлено е проектиран да бъде stateless и максимално прост, а Storage слоят предоставя елементарни I/O операции за четене и запис от файловата система, без вътрешна синхронизация. Това предотвратява фалшиво усещане за нишкова безопасност, при което отделни синхронизирани методи не могат да гарантират коректност на по-сложни операции, включващи няколко последователни извиквания.
Синхронизацията се реализира в командите чрез фино-гранулирано заключване по идентификатор на домейн обекта (напр. submission, course, assignment). По този начин:
- се гарантира, че конкурентни заявки, работещи върху един и същ обект, няма да доведат до race condition;
- се запазва възможността за паралелно изпълнение на несвързани операции;
- се постига атомарност на цялата бизнес операция, а не само на отделни файлови записи.
Този подход е особено подходящ в комбинация с използването на virtual threads, тъй като блокиращите операции не възпрепятстват мащабируемостта на сървъра.
Системата е проектирана с фокус върху минимална консумация на оперативна памет и ясно дефиниран източник на истина (Single Source of Truth). Repository слоят не поддържа вътрешни кешове или дългоживеещи структури от данни. Вместо това, при всяка команда необходимите обекти се:
- зареждат от файловата система;
- десериализират от JSON чрез библиотеката Gson;
- използват в рамките на конкретната операция;
- освобождават от паметта веднага след приключването ѝ.
Този модел предотвратява:
- прекомерно натоварване на RAM при големи обеми данни;
- проблеми със синхронизацията между кеширани обекти;
- несъответствия между състоянието в паметта и състоянието на диска.
При големи масиви от данни (например стотици мегабайти JSON файлове), зареждането им изцяло в паметта би довело до значително по-висока консумация на ресурси поради обектното представяне в Java. Директният достъп до диска осигурява предвидимост и стабилност на ресурсите, което е особено важно за сървърни приложения.
Архитектурата позволява бъдещи оптимизации като логическо сегментиране на файловете (напр. по хеш на ID или по начален символ), без промяна в бизнес логиката. С други думи sharding и разделяне по папки според началния символ на файла така че да могат да се търсят с по ефективни алгоритми и да зареждат по-бързо.
За постигане на висока пропускателна способност и коректност при конкурентен достъп, системата използва заключване по ключ (per-entity locking), реализирано чрез централен Lock Registry. Вместо да се блокира цялото repository или да се използват глобални синхронизирани секции, за всеки домейн обект се асоциира отделен ключ, идентифициран чрез неговото уникално ID.
Този подход заменя използването на String.intern(), както бях тръгнал да го правя и предоставя следните предимства:
- избягва използването на JVM String Pool като механизъм за синхронизация;
- предотвратява неконтролируемо задържане на обекти в паметта;
- осигурява ясен lifecycle на заключванията;
- позволява прецизен контрол върху конкурентния достъп.
Заключването се извършва в Command слоя, като всяка команда придобива заключване за конкретния обект преди модификация и го освобождава след завършване на операцията. По този начин администратор, работещ върху обект от „Курс А“, може паралелно да извършва операции с друг администратор върху „Курс Б“, без взаимно блокиране.
За управление на зависимостите проектът използва централизиран CodeCheckContext, реализиран чрез Builder design patter.
- Централизирано управление: Контекстът съхранява инстанциите на хранилища (
repositories) и услуги (services). - Гъвкавост: Позволява лесна подмяна на компоненти (напр. различни Storage стратегии) и гарантира, че всяка команда има достъп до необходимите ресурси чрез единна точка.
Системата използва хибриден подход за управление на състоянието, комбинирайки stateless repositories с "stateful"
управление на активните връзки чрез SelectionKey прикачвания.
Всеки клиентски канал (SocketChannel) има асоцииран обект ConnectionState.
Това е "паметта" на сървъра за конкретната връзка, която позволява:
- Разделяне на протоколите: Поддържа два отделни буфера —
commandBuffer(за текстови JSON команди) иfileBuffer(за бинарни данни). Това предотвратява смесването на метаданни и файлово съдържание. - Upload State Machine: Управлява прехода между трите фази на качване:
- Validation Phase (
awaitingFileUpload): Командата е валидирана, но файлът още не е започнал да пристига. - Transfer Phase (
receivingFile): Сървърът е отворилFileChannelи активно записва байтове директно на диска. - Completion Phase: Проверка за интегритет чрез
expectedFileSizeспрямоbytesReceived.
- Validation Phase (
- Thread-Safety: Пълна синхронизация чрез вътрешен
lockобект, което позволява на нишките от пула безопасно да модифицират състоянието на връзката.
Класът DefaultSessionStore служи като кеш слой за активните потребителски сесии.
- Concurrent Mapping: Използва
ConcurrentHashMapза асоцииране наSocketChannelс обектаUser. - Performance vs. Truth: Макар и приложението да е проектирано като stateless, кеширането на потребителите в паметта с пестява интензивното четене от диска при всяка проверка за автентикация, без да нарушава "Single Source of Truth" модела за бизнес данните (курсове, оценки, решения).
За да не се блокира Selector нишката при работа с големи файлове, сървърът капсулира операциите в UploadTask.
- Record-based Tasking: Използва Java
recordза имутабилно предаване на контекста (канал, състояние и команда) към Worker нишките. - Async Processing: Това позволява файлът да се обработва, индексира и валидира асинхронно, докато сървърът продължава да приема нови команди от същия или други клиенти.
Процесът на качване в CodeCheck е проектиран да бъде максимално устойчив:
- Command Send: Клиентът изпраща
submit-assignmentс метаданни за пътя и името. - Server Validation: Сървърът проверява крайни срокове, членство в курс и права. Ако всичко е наред, маркира връзката като
awaitingFileUpload. - Binary Stream: Клиентът започва да излива байтовете на файла.
- Direct Write: Сървърът чете от сокета и пише директно в
currentFileChannel, като следи прогреса в реално време презConnectionState. - 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, за да звучат по формални и за да са на български
- Започнах като зареждах всичко от папките в Map-ове в repositories - сетих се че може да е проблем Определено ако имаме милиони файлове сегашната система ще е бавна, но ще работи, няма да крашне, докато ако зареждаме всичко в паметта, тя въобще няма да работи.
- После направих винаги да се четат като се зареждат файловете. Това искаше Storage и Repository synchronization на методи
- После се сетих че това ще е бавно защото може да пишем в 2 отделни файла и пак нишките ще се изчакват
- После се сетих че те имат уникални id-та и мога да ползвам string pool-a за да ги заключвам
- После прочетох няколко статии колко е лошо да ползваш .intern и да lock-ваш по това
- Запознах се с reentrant locks и как да работя със stateless API и зареждане на ресурси, понеже се оказа че зареждайки един и същи json 2 пъти, това са 2 различни инстанции
- После се отказах да го ползвам и направих map-a с нормални обекти. До тук добре! Сега дойде най-лошото!!!
- Отне ми 2 дни да разгадая как да прехвърлям файлове по мрежата:
- 8.1. Не исках просто да ги encode-вам и да ги пускам, защото това увеличава размера им
- 8.2. Също така как се пренасят файлове в неблокиращ режим???
- 8.3. След много четене, гледане на клипове и каране с ChatGpt успях да създам нещо работещо
- 8.4. Не е перфектно но работи, когато клиент пренася файлове по мрежата това не блокира комуникацията на другите клиенти
- 8.5. Стейтовете работят горе-долу окей.
- 8.6. Изпитвах сериозни трудности с валидацията на данни, но сегашното решение с 2 отговора е задоволително
- 8.7. Научих много за FileChannels, BlockingQueue и т.н.
- После, когато дойде време да се пробвам да компилирам файлове програматично забравих за 1000Mb файл, който бях създал със скрипт за тестване на преноса. Разбира се като го компилирах понеже той беше пълен с null конзолата се напълни с толкова много грешки, толкова бързо че компютърът ми крашна... За щастие командата ми беше ясно благодарение на курса по devops.
- Чатът ми с чата 🤧
- https://www.youtube.com/watch?v=a83Qbv3icas&t=1s
- https://www.youtube.com/watch?v=wxBcFbzaj9w
- https://www.youtube.com/watch?v=WeaB8pAGlDw
- https://www.youtube.com/watch?v=sFizpxHkIlI
- https://github.com/letuanle/youtube-java
- https://jenkov.com/tutorials/java-nio/file-channel.html
- https://www.baeldung.com/java-filechannel
- https://stackoverflow.com/questions/55782106/remote-file-transfer-using-java-nio
- https://stackoverflow.com/questions/10677130/java-transferring-big-files-over-channels-nio
- https://stackoverflow.com/questions/10367698/java-multiple-file-transfer-over-socket
- и още много които не запазих...