31 KiB
HTTP-сервер
В этом задании вам предстоит реализовать простой HTTP-сервер для хранения файлов.
На практике вы, скорее всего, будете использовать готовые библиотеки, в которых работа с HTTP уже реализована. Но полезно хотя бы раз сделать это «руками», чтобы лучше понять протокол: как клиент передаёт запрос, как сервер его читает и формирует ответ.
Формат сообщений и работу соединений описывает RFC 9112, семантику методов и заголовков — RFC 9110. В задании используется ограниченное подмножество HTTP/1.1, описанное ниже. Прочитайте условие целиком перед началом работы.
Запуск сервера
При запуске сервер получает адрес и порт для приёма соединений, рабочую директорию и ожидаемое доменное имя. Эти настройки можно задать параметрами командной строки или переменными окружения:
| Параметр | Переменная окружения | По умолчанию | Назначение |
|---|---|---|---|
--host |
SERVER_HOST |
0.0.0.0 |
Адрес прослушивания TCP-соединений |
--port |
SERVER_PORT |
8080 |
Порт сервера |
--working-directory |
SERVER_WORKING_DIRECTORY |
Нет | Абсолютный путь к существующей рабочей директории |
--server-domain |
SERVER_DOMAIN |
localhost |
Ожидаемое доменное имя в заголовке Host |
Параметр командной строки имеет приоритет над переменной окружения, а переменная окружения — над значением по умолчанию. Если рабочая директория не указана ни одним способом или задана пустой строкой, сервер завершается с кодом 1. В Python-заготовке эта настройка уже реализована.
Параметры host и port определяют, где сервер принимает TCP-соединения. Параметр server-domain используется для проверки заголовка Host в запросах; он не меняет адрес прослушивания и не требует настройки DNS.
Проверка готовности
Тестер ждёт готовности сервера не более 10 секунд, отправляя запросы GET / HTTP/1.1 с заголовком Host, равным server-domain. Готовность подтверждает любой корректный HTTP-ответ, включая ответ об ошибке.
Сервер должен отвечать на GET / во всех группах тестов. В G1 и G2 можно вернуть код 200 с коротким текстом в теле ответа. Начиная с G3, этот запрос должен возвращать список файлов и директорий в рабочей директории.
Если сервер не начнёт отвечать вовремя, тестер завершит запуск с ошибкой server did not start in 10 seconds. В этом случае проверьте вывод сервера в журнале тестирования: проблема может быть как в запуске, так и в обработке GET /.
Работа с файлами
Сервер поддерживает четыре метода: GET читает файл или содержимое директории, POST создаёт файл или директорию, PUT заменяет содержимое существующего файла, а DELETE удаляет файл или директорию. Реализовывать HEAD и OPTIONS не требуется.
Метод задаёт действие, а путь в запросе — файл или директорию, к которым это действие применяется. Все четыре метода используют одну схему путей: путь отсчитывается от рабочей директории сервера, заданной при запуске. Например, при --working-directory /srv/files запросы к /docs/file.txt работают с /srv/files/docs/file.txt. Начальный / в запросе обозначает рабочую директорию сервера, а не корень файловой системы компьютера. Отдельные адреса вроде /get или /upload создавать не нужно.
Ниже записи вида GET /docs/file.txt обозначают метод и путь запроса. В передаваемом по TCP HTTP-сообщении к ним добавляются версия протокола, заголовки и, при необходимости, тело.
GET: чтение файла или директории
Если путь указывает на файл, сервер возвращает его содержимое в теле ответа. Например, GET /docs/file.txt возвращает байты файла /srv/files/docs/file.txt.
Если путь указывает на директорию, сервер возвращает список её непосредственных дочерних файлов и директорий — листинг. Так, GET /docs возвращает имена объектов внутри /srv/files/docs, а GET / — внутри самой рабочей директории. Вложенные директории обходить не нужно.
В списке должны присутствовать все имена, включая скрытые файлы, но без . и ... Порядок и оформление произвольные: можно вернуть текст или HTML. Размеры, даты и права доступа не требуются; дополнительные сведения допустимы.
POST: создание файла или директории
Путь в POST-запросе задаёт имя создаваемого объекта целиком. Например, POST /docs/new.txt создаёт файл /srv/files/docs/new.txt и записывает в него тело запроса. Директория /srv/files/docs к этому моменту должна существовать. Если тело пустое, создаётся пустой файл.
Чтобы создать директорию, клиент передаёт заголовок Create-Directory: True. Например, POST /docs/archive с этим заголовком создаёт пустую директорию /srv/files/docs/archive. Сервер создаёт только указанный объект: промежуточные директории автоматически не создаются. Если по этому пути уже есть файл или директория, POST завершается ошибкой и не заменяет их.
PUT: замена содержимого файла
PUT записывает тело запроса в существующий файл по указанному пути. Например, PUT /docs/file.txt полностью заменяет содержимое /srv/files/docs/file.txt. Если новые данные короче прежних, остаток прежнего содержимого должен исчезнуть; пустое тело делает файл пустым.
В этом задании PUT применяется только к существующим файлам. Создавать новый файл или заменять директорию этим методом нельзя.
DELETE: удаление файла или директории
Запрос DELETE /docs/file.txt удаляет файл /srv/files/docs/file.txt. Для удаления директории клиент должен явно передать Remove-Directory: True. Например, DELETE /docs/archive с этим заголовком удаляет /srv/files/docs/archive вместе со всеми вложенными файлами и директориями. Без разрешающего заголовка сервер должен отказать в удалении директории, даже если она пустая.
Коды ответов и общие правила
В таблице собраны коды ответов для всех перечисленных операций. Там, где указаны два кода, можно выбрать любой из них.
| Запрос и состояние пути | Действие и ответ |
|---|---|
| GET существующего файла | Вернуть содержимое файла с кодом 200 |
| GET существующей директории | Вернуть список её непосредственных дочерних файлов и директорий с кодом 200 |
| GET отсутствующего пути | Вернуть 404 |
| POST нового объекта, родительская директория существует | Создать объект и вернуть 200 или 201 |
| POST существующего файла или директории | Вернуть 409 |
| POST без родительской директории | Вернуть 404, не создавать промежуточные директории |
| PUT существующего файла | Полностью заменить содержимое телом запроса и вернуть 200 или 204 |
| PUT директории | Вернуть 409 |
| PUT отсутствующего пути | Вернуть 404, не создавать файл |
| DELETE существующего файла | Удалить файл и вернуть 200 |
DELETE / |
Вернуть 403: удаление рабочей директории запрещено независимо от заголовка Remove-Directory |
DELETE директории с Remove-Directory: True |
Удалить директорию со всем содержимым и вернуть 200 |
DELETE директории без Remove-Directory: True |
Вернуть 406 |
| DELETE отсутствующего пути | Вернуть 404 |
Заголовки Create-Directory и Remove-Directory включают соответствующее действие только со значением True. Значение False равнозначно отсутствию заголовка; других значений в запросах не будет.
Для упрощения работы с путями в тестах используются только ASCII-имена из букв, цифр, дефиса, подчёркивания и точки. Компоненты . и .., параметры после ?, percent-encoding и символические ссылки исключены. Если один из промежуточных компонентов пути является файлом, такой путь обрабатывается как отсутствующий.
При ошибке файловая система должна остаться без изменений. Ответ об ошибке содержит непустой понятный текст; точная формулировка не задана. Успешные ответы на POST, PUT и DELETE могут иметь пустое тело. Ответ 204 всегда передаётся без тела.
HTTP и заголовки
Каждое соединение содержит один запрос. Сервер отправляет ответ с Connection: close и закрывает соединение. Одновременная обработка нескольких соединений не требуется.
Запросы синтаксически корректны и используют HTTP/1.1. В них нет повторяющихся заголовков, Transfer-Encoding и Expect. Длина тела задаётся корректным Content-Length; если заголовка нет, тела нет. Имена заголовков сравниваются без учёта регистра. Неизвестные заголовки можно игнорировать.
TCP передаёт поток байтов: результат одного чтения из сокета может содержать часть заголовка либо конец заголовков вместе с началом тела. Сервер должен работать при любом таком разбиении и не ждать закрытия соединения клиентом, чтобы определить конец запроса. Файлы могут содержать произвольные байты.
| Заголовок | Требование |
|---|---|
Host в запросе |
В функциональных тестах содержит доменное имя без порта. Сравнивается с server-domain без учёта регистра; при несовпадении вернуть 400, не выполняя файловую операцию |
Content-Length в ответе |
Обязателен, кроме ответа 204. Равен размеру передаваемого тела в байтах; для пустого тела — 0, для gzip — размеру сжатых данных. В ответе 204 этот заголовок запрещён |
Connection в ответе |
close |
Content-Type в ответе |
Описывает тип содержимого тела. Для непустого тела нужен корректный MIME-тип. Для листинга допустимы text/plain и text/html. Для любых файлов, включая текстовые, разрешено использовать application/octet-stream; определять MIME-тип по расширению не требуется |
Server в ответе |
Непустое название сервера |
Accept-Encoding в запросе |
Сообщает, что клиент готов принять сжатые данные. В задании поддерживается только значение gzip |
Content-Encoding в ответе |
Сообщает, каким алгоритмом сжато тело ответа. При сжатии имеет значение gzip; в несжатом ответе отсутствует |
Проверка Host, Content-Type и Server входит в G5 и G7. Коды ответа, длина и границы тела, закрытие соединения и заданное условием содержимое проверяются во всех группах.
В G7 успешные GET-ответы на запрос с Accept-Encoding: gzip должны содержать сжатые данные. Это относится и к файлам, и к листингам. Без этого заголовка ответ не сжимается и не содержит Content-Encoding. Сжатие ошибок не требуется. Готовые библиотеки gzip использовать можно. Transfer-Encoding в ответах не используется.
Компоненты и оценивание
Автоматические тесты определяют предварительную оценку. Можно реализовать часть задания: каждая группа проверяет только перечисленные для неё возможности. Например, в G1 достаточно чтения существующих текстовых файлов и ответа на служебный GET /, а создание и удаление файлов появляются в G4.
Для получения баллов за группу нужно пройти все её проверки. Группы оцениваются независимо: ошибка в одной группе не лишает баллов за успешно пройденные другие. За все семь групп можно получить 10 баллов.
| Группа | Баллы | Что проверяется |
|---|---|---|
| G1 | 3 | GET существующих текстовых файлов в ASCII, размером ≤ 8 МБ; настройка и запуск сервера |
| G2 | 1 | GET существующих файлов с произвольными байтами, размером ≤ 8 МБ |
| G3 | 1 | GET файлов и директорий, ошибки отсутствующих путей; файлы в ASCII, размером ≤ 8 МБ |
| G4 | 2 | GET, POST, PUT и DELETE, листинг и ошибки; произвольные файлы размером ≤ 8 МБ |
| G5 | 1 | Возможности G4 и проверка Host, Content-Type, Server |
| G6 | 1 | Возможности G4 и работа с большими файлами |
| G7 | 1 | Возможности G6, дополнительные заголовки из G5 и gzip |
В G1 и G3 используются текстовые файлы в кодировке ASCII. В остальных группах файлы могут содержать произвольные байты, поэтому их содержимое нельзя считать текстом.
В G1–G5 размер каждого файла не превышает 8 МБ. Это ограничение действует и на содержимое файлов, передаваемое в запросах POST и PUT. В G6 и G7 файлы могут быть больше 8 МБ. В этом задании 1 МБ = 1024 × 1024 байта.
В тестирующей системе решение работает в Docker с лимитом памяти 128 МБ. В G6 и G7 файлы могут превышать объём доступной памяти: нужно уметь и отдавать их клиенту, и принимать при POST и PUT, не загружая целиком в память. Проверить соблюдение лимита можно локально с Docker по инструкции ниже. Запуск без Docker этот лимит не проверяет.
Для передачи большого файла с gzip и корректным Content-Length можно предварительно сжать его во временный файл, определить размер результата и затем передать его клиенту по частям. Само сжатие также должно укладываться в ограничение памяти.
Отчёт и защита
Вместе с кодом сдайте краткий отчёт solution/readme.md. Опишите устройство решения и используемые библиотеки, а также объясните следующие механизмы, указав соответствующие файлы и функции в своём коде:
- как определяются границы заголовков и тела;
- как обрабатываются большие файлы и от чего зависит расход памяти, если реализованы G6/G7;
- как определяется длина сжатого ответа, если реализована G7.
Без отчёта автоматические тесты запускаются, но решение не засчитывается по общим правилам сдачи. Использование LLM укажите согласно политике курса.
На защите нужно объяснить сданную реализацию, разобрать предложенный сценарий и при необходимости внести небольшую правку либо проследить выполнение кода. Ответы подтверждают баллы соответствующих компонентов; прохождение тестов само по себе не гарантирует итоговую оценку.
Если вы используете заготовку, на защите нужно понимать, как ваш код взаимодействует с ней: как запускается сервер, откуда берутся параметры и как соединение передаётся обработчику запроса.
Заготовка и ограничения реализации
Нельзя использовать готовые библиотеки HTTP или парсеры HTTP-сообщений, в том числе из стандартной библиотеки языка. Можно использовать TCP-сокеты, socketserver, библиотеки для файлов, командной строки и gzip.
В solution находится Python-заготовка с настройкой параметров, TCP-сервером, логированием и структурами сообщений. Вам предстоит реализовать чтение и разбор запроса, обработку методов и формирование ответа. Методы в http_messages.py предназначены для стартовой строки и заголовков; тело обрабатывается отдельно. Наличие констант других методов не означает, что их нужно поддерживать.
Можно изменить структуру заготовки или выбрать другой язык; использовать именно её классы необязательно. Решение должно поддерживать описанные параметры командной строки и переменные окружения. В solution/Dockerfile должно быть описание сборки образа и запуска вашего сервера. Если вы меняете язык или способ запуска, обновите этот файл.
Весь код решения, необходимые для сборки файлы и отчёт разместите в папке solution. При сдаче отправляется только эта папка; изменения в тестах, tests/launch.tmpl и других файлах вне неё в тестирующую систему не попадут.
Тестирование
Тесты написаны на Go и находятся в папке tests; точка входа — TestHW. Во время разработки удобно запускать их без Docker для быстрой проверки после изменений. Перед сдачей проверьте решение и с Docker: этот режим воспроизводит окружение тестирующей системы и ограничение памяти.
Как устроены тесты
Внутри каждой группы есть несколько запусков. Для каждого запуска тестер создаёт набор файлов и директорий, запускает ваш сервер с соответствующей рабочей директорией и отправляет ему последовательность HTTP-запросов. Сервер работает до конца этого запуска, поэтому изменения после POST, PUT и DELETE влияют на последующие запросы. Запуск считается успешным, если сервер правильно обработал все запросы; для получения баллов нужно пройти все запуски группы.
Наборы файлов и запросов генерируются случайным образом, но воспроизводятся при повторном запуске той же версии тестов. Числовые идентификаторы в журнале позволяют повторить нужную последовательность. Помимо сгенерированных запросов, есть отдельные проверки конкретных случаев, например пустого файла или разбиения запроса между несколькими чтениями TCP-потока. Тесты помогают находить ошибки, но не заменяют проверку соответствия всему условию.
Без Docker
Установите Go. В tests/launch.tmpl задана команда, которой тестер запускает сервер. При необходимости измените её для своей системы или языка; в Windows вместо python3 может потребоваться python. Сохраните в шаблоне {{.CommandLineArgs}}: на это место тестер подставляет параметры запуска.
Если используете Python-заготовку, установите её зависимости. Затем запустите тесты следующими командами из папки задания:
python3 -m pip install -r solution/requirements.txt
cd tests
go test
Для отдельной группы или запуска используйте фильтр:
go test -run 'TestHW/G1'
go test -run 'TestHW/G1/1337'
Имена проверок видны в журнале. Например, TestHW/G2/93/42300 обозначает группу G2, запуск 93 и запрос 42300. В G1 и G2 запросы только читают файлы, поэтому можно повторить отдельный запрос, указав его полный идентификатор: go test -run 'TestHW/G2/93/42300'.
В группах с POST, PUT и DELETE запросы используют общее изменяемое состояние файлов. Поэтому ошибку в такой группе воспроизводите всем запуском: отдельный запрос может получить другое начальное состояние.
С Docker
Перед первым запуском и после объявления об обновлении тестов загрузите образ:
docker pull distsys.ru/course/http-server:latest
Запускайте из папки задания:
docker run --privileged --rm -v ./solution:/hw/solution distsys.ru/course/http-server:latest
Для отдельной группы добавьте -run 'TestHW/G1' после имени образа. Тестер собирает контейнер вашего сервера и запускает его с лимитом памяти 128 МБ. Общий лимит запуска образа тестов — 10 минут. Скорость зависит от компьютера и Docker-окружения.
Можно собрать образ сервера самостоятельно и проверить его локальным тестером:
cd solution
docker build -t hw3img .
cd ../tests
go test --docker -timeout 10m
Для сборки самого образа тестов выполните из папки задания:
docker build -t hw3tests ./tests
docker run --privileged --rm -v ./solution:/hw/solution hw3tests
Как читать результаты
В журнале видны команда запуска с параметрами и переменными окружения, весь вывод сервера в stdout/stderr и причины ошибок. При отладке начните с первого неудачного запроса: его идентификатор позволяет повторить соответствующий запуск с помощью фильтра -run.
После ошибки оставшиеся запросы этого запуска и оставшиеся запуски группы пропускаются, а за группу начисляется 0 баллов. Тестирование других групп продолжается. В конце выводятся баллы по группам и строка SCORE: N. Если использовался фильтр, эта оценка учитывает только выбранные проверки; оценку за всё задание показывает полный прогон.
После локальной проверки отправьте решение по общей инструкции.
Бонус за пробелы в тестах
Если найдёте ошибку в тестах или требование, нарушение которого они не обнаруживают, опишите ситуацию в отчёте; при необходимости приложите пример ошибочного решения. За подтверждённую проблему можно получить 1 бонусный балл, а за тест, обнаруживающий её, или описание его логики — ещё 1 балл.