Если вы разработчик на php вы наверняка уже сталкивались с HTTP-серверами, такими как: apache, nginx или OpenServer.
Зачем?
Причин может быть несколько, но самая основная это обучение. Чтобы расширить своё понимание протокола HTTP, и как с ним можно работать.
Как работает HTTP-сервер?
Любой веб-сервер делает три вещи:
Слушает порт.
Принимает соединения и читает сырой HTTP-запрос.
Отправляет ответ.
В PHP для этого есть функция stream_socket_server()
Теория
HTTP Текстовый формат, все заголовки разделяются переносом строки, двойной перенос говорит о том что дальше следует тело ответа. Пример запроса:
GET /dfgdfg HTTP/1.1
Host: lumetas.ru:8000
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: ru-RU,ru;q=0.9
Cookie: property=value;property1=value1
В первой строки указывается, и адрес запроса, а так же версия HTTP, дальше идут заголовки и куки которые отдаёт клиент, разделённые через точку с запятой
После сервер возвращает похожий текст, например:
HTTP/1.1 200 OK
Date: Thu, 29 Jul 2021 19:20:01 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 15
Connection: close
Server: gunicorn/19.9.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly
<div>text</div>
Здесь указывается статус и версия протокол http тип контента, указывает браузеру на то какого формата данные, их длина. И другие произвольные данные, так же вы можете видеть как устанавливаются cookie
Прослушивание
Реализуем класс и метод, который будет прослушивать входящие запросы
class SimpleHttpServer
{
private $socket;
public function __construct(private string $host = '0.0.0.0', private int $port = 8000) {} // Устанавливаем свойства класса
public function listen(callable $requestHandler): void // Создаём метод который будет использоваться нами в дальнейшем для работы с классом
{
$this->createSocket(); // используем метод создание сокета
echo "Server running on http://{$this->host}:{$this->port}\n"; //Выводим в консоль информацию о том что сервер запущен
while ($conn = stream_socket_accept($this->socket, -1)) {// Ждём запрос
$request = fread($conn, 8192); // Читаем 8КБ данных из запроса
$requestInfo = $this->parseRequest($request); // Парсим запрос
$response = $requestHandler($requestInfo); // Передаём в нашу callback функцию
$this->sendResponse($conn, $response); // возвращаем ответ
fclose($conn);
}
}
}
Далее методы для создания сокета и отправки ответа:
private function createSocket(): void // Метод для создания сокета
{
$this->socket = stream_socket_server(
"tcp://{$this->host}:{$this->port}", // Указываем хост и порт для сокета
// Переменные для ошибок
$errno,
$errstr
);
if (!$this->socket) {
throw new RuntimeException("Failed to create socket: $errstr ($errno)"); // Выкидываем исключение в случае ошибки создания сокета
}
}
private function sendResponse($conn, array|string $response): void // Метод для отправки ответ
{
if (is_array($response)) { // Если на вход передан массив то формируем заголовки
$status = $response['status'] ?? 200; // Статус 200 - успех
$headers = $response['headers'] ?? ['Content-Type' => 'text/plain']; // Массив заголовков
$body = $response['body'] ?? ''; // Тело ответа
$headerString = "HTTP/1.1 $status OK\r\n"; // Строка заголовка
foreach ($headers as $name => $value) { // Перебираем массив заголовков и составляем строку
$headerString .= "$name: $value\r\n";
}
fwrite($conn, $headerString . "\r\n" . $body); // В конце добавляем ещё перенос строки так чтобы получилась пустая строка и записываем в сокет
} else {
fwrite($conn, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n$response"); // Если передана строка возвращаем
}
}
Теперь мы можем отдавать ответ пользователю, но формировать он должен на основе запроса, так что напишем методы для парсинга запроса:
private function parseRequest(string $request): array
{
$lines = explode("\r\n", $request); // Разбиваем запрос по переносы строки
$firstLine = explode(' ', $lines[0]); // Разбиваем первую строку по пробелу
return [ // Возвращаем распаршенный запрос
'method' => $firstLine[0] ?? 'GET', // Определяем метод, если он не указан, пишем GET
'path' => $firstLine[1] ?? '/', // Определяем запрашиваемую локацию
'headers' => $this->extractHeaders($lines), // Извлекаем заголовки
'body' => $this->extractBody($lines), // Извлекаем тело запроса
'raw' => $request // И добавляем сырой запрос
];
}
private function extractHeaders(array $lines): array
{
$headers = []; // Создаём массив заголовков
foreach ($lines as $line) { // Перебираем строки
if (strpos($line, ':') !== false) { // Если есть двоеточие
[$name, $value] = explode(':', $line, 2); // Тогда сохраняем имя заголовка и значение
$headers[trim($name)] = trim($value); // Добавляем в массив, обрезая пустые символы
}
}
return $headers; // Возвращаем
}
private function extractBody(array $lines): string
{
$bodyStart = array_search('', $lines) + 1; // Ищем строку с которой начинается тело, по пустой строке
return implode("\r\n", array_slice($lines, $bodyStart)); //Соеденяем всё тело как было и возвращаем
}
Класс сервера готов, теперь можем его использовать:
<?php
require "SimpleHttpServer.php";
$server = new SimpleHttpServer();
$server->listen(function($request) {
echo "$request[method] $request[path]\n";
if ($request['path'] === '/hello') {
return "Hello World!";
}
return [
'status' => 404,
'body' => 'Not Found'
];
});
Запустить можно запустив главный файл php index.php
Заключение
Мы написали рабочий HTTP сервер на PHP и разобрались с протоколом. Финальный код с комментариями доступен на github