Search
Write a publication
Pull to refresh

Пишем 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

Tags:
Hubs:
You can’t comment this publication because its author is not yet a full member of the community. You will be able to contact the author only after he or she has been invited by someone in the community. Until then, author’s username will be hidden by an alias.