Как стать автором
Обновить

Пишем HTTP-сервер на php и сокетах

Если вы разработчик на php вы наверняка уже сталкивались с HTTP-серверами, такими как: apache, nginx или OpenServer.

Зачем?

Причин может быть несколько, но самая основная это обучение. Чтобы расширить своё понимание протокола HTTP, и как с ним можно работать.

Как работает HTTP-сервер?

Любой веб-сервер делает три вещи:

  1. Слушает порт.

  2. Принимает соединения и читает сырой HTTP-запрос.

  3. Отправляет ответ.

В 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

Теги:
Хабы:
Данная статья не подлежит комментированию, поскольку её автор ещё не является полноправным участником сообщества. Вы сможете связаться с автором только после того, как он получит приглашение от кого-либо из участников сообщества. До этого момента его username будет скрыт псевдонимом.