Рендеринг HTML файлов: глава из книги «ReactPHP для начинающих» от разработчика Skyeng

  • Tutorial


Бэкенд-разработчик мобильного приложения Skyeng Сергей Жук продолжает писать годные книги. На сей раз он выпустил учебник на русском языке для только осваивающей PHP аудитории. Я попросил Сергея поделиться полезной самодостаточной главой из его книги, ну и дать читателям Хабры скидочный код. Ниже — и то, и другое.


Для начала расскажем, на чем мы остановились в предыдущих главах.

Мы написали свой простой HTTP-сервер на PHP. У нас есть основной файл index.php — скрипт, который запускает сервер. Здесь находится самый высокоуровневый код: мы создаем цикл событий, настраиваем поведение HTTP-сервера и запускаем цикл:


use React\Http\Server;
use Psr\Http\Message\ServerRequestInterface;

$loop = React\EventLoop\Factory::create();
$router = new Router();
$router->load('routes.php');

$server = new Server(
  function (ServerRequestInterface $request) use ($router) {
    return $router($request);
  }
);

$socket = new React\Socket\Server(8080, $loop);
$server->listen($socket);
$loop->run();

Для маршрутизации запросов сервер использует роутер:


// src/Router.php

use Psr\Http\Message\ServerRequestInterface;
use React\Http\Response;

class Router
{
  private $routes = [];

  public function __invoke(ServerRequestInterface $request)
  {
    $path = $request->getUri()->getPath();
    echo "Request for: $path\n";

    $handler = $this->routes[$path] ?? $this->notFound($path);
    return $handler($request);
  }

  public function load($filename)
  {
    $routes = require $filename;
    foreach ($routes as $path => $handler) {
      $this->add($path, $handler);
    }
  }

  public function add($path, callable $handler)
  {
    $this->routes[$path] = $handler;
  }

  private function notFound($path)
  {
    return function () use ($path) {
      return new Response(
        404,
        ['Content-Type' => 'text/html; charset=UTF-8'],
        "No request handler found for $path"
      );
    };
  }
}

В роутер загружаются маршруты из файла routes.php. Сейчас здесь объявлено всего два маршрута:


use React\Http\Response;
use Psr\Http\Message\ServerRequestInterface;

return [
  '/' => function (ServerRequestInterface $request) {
    return new Response(
      200, ['Content-Type' => 'text/plain'], 'Main page'
    );
  },
  '/upload' => function (ServerRequestInterface $request) {
    return new Response(
      200, ['Content-Type' => 'text/plain'], 'Upload page'
    );
  },
];

Пока всё просто, и наше асинхронное приложение умещается в нескольких файлах.


Переходим к более “полезным” вещам. Ответы из пары слов обычного текста, которые мы научились выводить в предыдущих главах, выглядят не очень привлекательно. Нам нужно возвращать что-то реальное, например, HTML-страницу.


Так, а куда же нам положить этот HTML? Конечно, можно захардкодить содержимое веб-страницы прямо внутри файла с маршрутами:


// routes.php
return [
  '/' => function (ServerRequestInterface $request) {
    $html = <<<HTML
<!DOCTYPE html>
<html lang=”en”>
<head>
  <meta charset=”UTF-8”>
  <title>ReactPHP App</title>
</head>
<body>
  Hello, world
</body>
</html>
HTML;
    return new Response(
      200, ['Content-Type' => 'text/html'], $html
    );
  },
  '/upload' => function (ServerRequestInterface $request) {
    return new Response(
      200, ['Content-Type' => 'text/plain'], 'Upload page'
    );
  },
];

Но не надо так делать! Нельзя смешивать бизнес логику (маршрутизация) с представлением (HTML-страница). Почему? Представьте, что вам нужно будет поменять что-нибудь в HTML-коде, например, цвет кнопки. И какой файл при этом нужно будет изменить? Файл с маршрутами router.php? Звучит странно, не так ли? Вносить изменения в маршрутизацию, чтобы поменять цвет кнопки...


Поэтому оставим маршруты в покое, а для HTML-страниц создадим отдельную директорию. В корне проекта добавим новую директорию pages. Затем внутри нее создаем файл index.html. Это будет наша главная страница. Вот ее содержимое:


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>ReactPHP App</title>
  <link
    rel="stylesheet"
    href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css"
  >
</head>
<body>
  <div class="container">
    <div class="row">
      <form action="/upload" method="POST" class="justify-content-center">
        <div class="form-group">
          <label for="text">Text</label>
          <textarea name="text" id="text" class="form-control">
          </div>
          <button type="submit" class="btn btn-primary">Submit</button>
      </form>
    </div>
  </div>
</body>
</html>

Страница довольно простая, содержит всего один элемент — форму. У формы внутри есть текстовое поле и кнопка для отправки. Я также добавил стили Bootstrap, чтобы наша страничка выглядела более симпатично.


Чтение файлов. Как НЕ надо делать


Самый прямолинейный подход подразумевает чтение содержимого файла внутри обработчика запроса и возвращение этого содержимого в качестве тела ответа. Что-то вроде этого:


// routes.php
return [
  '/' => function (ServerRequestInterface $request) {
    return new Response(
      200, ['Content-Type' => 'text/html'], file_get_contents('pages/index.html')
    );
  },
  // ...
];

И, кстати, это сработает. Можете сами попробовать: перезапустите сервер и перезагрузите страницу http://127.0.0.1:8080/ в своем браузере.



Так и что же здесь неправильно? И почему так нельзя делать? Если коротко, потому, что возникнут проблемы, если файловая система начнет тормозить.


Блокирующие и неблокирующие вызовы


Позвольте продемонстрировать, что я имею в виду под “блокирующими” вызовами, и что может произойти, когда в одном из обработчиков запроса окажется блокирующий код. Перед возвращением объекта ответа добавьте вызов функции sleep():


// routes.php

return [
  '/' => function (ServerRequestInterface $request) {
    sleep(10);
    return new Response(
      200, ['Content-Type' => 'text/html'], file_get_contents('pages/index.html')
    );
  },
  '/upload' => function (ServerRequestInterface $request) {
    return new Response(
      200, ['Content-Type' => 'text/plain'], 'Upload page'
    );
  },
];

Это заставит обработчик запроса зависнуть на 10 секунд, прежде чем он сможет вернуть ответ с содержимым HTML страницы. Обратите внимание, что обработчик для адреса /upload мы не трогали. Вызывая функцию sleep(10), я эмулирую выполнение какой-либо блокирующей операции.


Итак, что мы имеем? Когда браузер запрашивает страницу /, обработчик ждет 10 секунд и затем возвращает HTML страницу. Когда мы открываем адрес /upload, его обработчик должен сразу же вернуть ответ со строкой 'Upload page'.


А теперь давайте посмотрим, что будет на самом деле. Как всегда, перезапускаем сервер. А теперь, пожалуйста, откройте еще одно окно в браузере. В строке адреса введите http://127.0.0.1:8080/upload, но не открывайте сразу эту страницу. Просто пока оставьте этот адрес в адресной строке. Затем перейдите к первому окну браузера и откройте в нем страницу http://127.0.0.1:8080/. Пока эта страница загружается (помните, что ей понадобится на это 10 секунд), быстро перейдите ко второму окну и нажмите “Enter”, чтобы загрузить адрес, который был оставлен в адресной строке (http://127.0.0.1:8080/upload).


Что же мы получили? Да, адрес /, как и ожидалось, загружается 10 секунд. Но, к удивлению, второй странице понадобилось столько же времени на загрузку, хотя для нее мы никаких вызовов sleep() не добавляли. Есть идеи, почему так произошло?


ReactPHP выполняется в одном потоке. Может казаться, что в асинхронном приложении задачи выполняются параллельно, но на самом деле это не так. Иллюзию параллельности создает цикл событий, который постоянно переключается между различными задачами и выполняет их. Но в определенный момент времени всегда выполняется только одна задача. Это означает, что если одна из таких задач выполняется слишком долго, то она заблокирует цикл событий, который не сможет регистрировать новые события и вызывать для них обработчики. И что в итоге приведет к “зависанию” всего приложения, оно просто потеряет асинхронность.


Хорошо, но какое это отношение имеет к вызову file_get_contents('pages/index.h')? Проблема здесь заключается в том, что мы обращаемся напрямую к файловой системе. По сравнению с остальными операциями, такими, как работа с памятью или вычисления, работа с файловой системой может быть чрезвычайно медленной. К примеру, если файл оказался слишком большой, или сам диск медленный, то чтение файла может занять определенное время и в результате заблокировать цикл событий.


В стандартной синхронной модели запрос — ответ это не является проблемой. Если клиент запросил слишком тяжелый файл, то он подождет, пока этот файл будет загружен. Такой тяжелый запрос никак не повлияет на остальных клиентов. Но в нашем случае мы имеем дело с асинхронной событийноориентированной моделью. У нас запущен HTTP сервер, который должен постоянно обрабатывать входящие запросы. Если один запрос требует слишком много времени на выполнение, то это затронет всех остальных клиентов сервера.


В качестве правила следует запомнить:


  • Никогда нельзя блокировать цикл событий.

Так, а как же нам тогда прочитать файл асинхронно? И здесь мы подходим ко второму правилу:


  • Когда нельзя избежать блокирующей операции, следует ее форкнуть в дочерний процесс и продолжить асинхронное выполнение в основном потоке.

Итак, после того как мы узнали как не надо делать, давайте обсудим правильное неблокирующее решение.


Дочерний процесс


Всё общение с файловой системой в асинхронном приложении должно выполняться в дочерних процессах. Для управления дочерними процессами в ReactPHP приложении нам нужно установить еще один компонент "Child Process". Данный компонент позволяет получить доступ к функциям операционной системы для запуска любой системной команды внутри дочернего процесса. Чтобы установить этот компонент, откройте терминал в корне проекта и выполните следующую команду:


composer require react/child-process


Совместимость с Windows


В операционной системе Windows потоки STDIN, STDOUT и STDERR являются блокирующими, это означает, что компонент Child Process не сможет корректно работать. Поэтому данный компонент в основном рассчитан на работу только в nix системах. Если вы попытаетесь создать объект класса Process на системе Windows, то будет выброшено исключение. Но компонент может работать под Windows Subsystem for Linux (WSL). Если вы собираетесь использовать этот компонент под Windows, нужно будет установить WSL.


Теперь мы можем выполнять любую команду оболочки внутри дочернего процесса. Откройте файл routes.php, а затем давайте изменим обработчик для маршрута /. Создайте объект класса React\ChildProcess\Process, а в качестве команды передайте ему ls для получения содержимого текущей директории:


// routes.php

use Psr\Http\Message\ServerRequestInterface;
use React\ChildProcess\Process;
use React\Http\Response;

return [
  '/' => function (ServerRequestInterface $request) {
    $childProcess = new Process('ls');

    return new Response(
      200, ['Content-Type' => 'text/html'], file_get_contents('pages/index.html')
    );
  },
  // ...
];

Затем нам нужно запустить процесс, вызвав метод start(). Загвоздка в том, что методу start() нужен объект цикла событий. Но в файле routes.php у нас нет этого объекта. Как же нам передать цикл событий из index.php в маршруты прямо в обработчик запроса? Решением этой проблемы является “инъекция зависимостей”.


Инъекция зависимостей


Итак, одному из наших маршрутов для работы нужен цикл событий. В нашем приложении только один компонент знает о существовании маршрутов — класс Router. Выходит, что это его обязанность — предоставить цикл событий для маршрутов. Другими словами, маршрутизатору нужен цикл событий, или он зависит от цикла событий. Как же нам явно выразить эту зависимость в коде? Как сделать так, чтобы нельзя было даже создать маршрутизатор, не передав ему цикл событий? Конечно, через конструктор класса Router. Откроем Router.php и добавим классу Router конструктор:


use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\LoopInterface;
use React\Http\Response;

class Router
{
  private $routes = [];
  /**
   * @var LoopInterface
   */
  private $loop;

  public function __construct(LoopInterface $loop)
  {
    $this->loop = $loop;
  }

  // ...
}

Внутри конструктора сохраним переданный цикл событий в приватном свойстве $loop. Это и есть инъекция зависимостей, когда мы снаружи предоставляем классу необходимые ему для работы объекты.


Теперь, когда у нас есть этот новый конструктор, нужно обновить создание маршрутизатора. Откроем файл index.php и поправим строчку, где мы создаем объект класса Router:


// index.php

$loop = React\EventLoop\Factory::create();

$router = new Router($loop);
$router->load('routes.php');

Готово. Возвращаемся назад к routes.php. Как вы, наверное, уже догадались, здесь мы можем использовать всю ту же идею с инъекцией зависимостей и добавить цикл событий как второй параметр к нашим обработчикам запросов. Изменим первый колбэк и добавим второй аргумент: объект, реализующий LoopInterface:


// routes.php

use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\LoopInterface;
use React\ChildProcess\Process;
use React\Http\Response;

return [
  '/' => function (ServerRequestInterface $request, LoopInterface $loop) {
    $childProcess = new Process('ls');
    $childProcess->start($loop);

    return new Response(
      200, ['Content-Type' => 'text/html'], file_get_contents('pages/index.html')
    );
  },
  '/upload' => function (ServerRequestInterface $request) {
    return new Response(
      200, ['Content-Type' => 'text/plain'], 'Upload page'
    );
  },
];

Далее нам нужно передать цикл событий в метод start() дочернего процесса. А где же обработчик получит цикл событий? А он уже сохранен внутри маршрутизатора в приватном свойстве $loop. Нам всего лишь нужно передать его при вызове обработчика.


Откроем класс Router и обновим метод __invoke(), добавив второй аргумент к вызову обработчика запроса:


public function __invoke(ServerRequestInterface $request)
{
  $path = $request->getUri()->getPath();
  echo "Request for: $path\n";

  $handler = $this->routes[$path] ?? $this->notFound($path);
  return $handler($request, $this->loop);
}

Вот и все! На этом, пожалуй, хватит инъекций зависимостей. Немаленькое такое путешествие у цикла событий получилось, да? Из файла index.php в класс Router, а затем из класса Router в файл routes.php прямо внутрь колбэков.


Итак, чтобы подтвердить, что дочерний процесс сделает свою неблокирующую магию, давайте заменим простую команду ls на более тяжелую ping 8.8.8.8. Перезапустим сервер и снова попробуем открыть две страницы в двух разных окнах. Сначала http://127.0.0.1:8080/, а затем /upload. Обе страницы откроются быстро, без каких-либо задержек, хотя в первом обработчике в фоне выполняется команда ping. Это, кстати, означает, что мы можем форкнуть любую дорогостоящую операцию (например, обработку больших файлов), при этом не блокируя основное приложение.


Связываем дочерний процесс и ответ с помощью потоков


Вернемся к нашему приложению. Итак, мы создали дочерний процесс, запустили его, но наш браузер никак не отображает результаты форкнутой операции. Давайте это исправлять.


Каким образом мы можем общаться с дочерним процессом? В нашем случае у нас есть запущенная команда ls, которая выводит на экран содержимое текущей директории. Как же нам заполучить этот вывод, а затем направить его в тело ответа? Короткий ответ: потоки.


Давайте немного поговорим о процессах. Любая команда оболочки, которую вы выполняете, имеет три потока данных: STDIN, STDOUT и STDERR. По потоку на стандартный вывод и ввод, плюс поток для ошибок. К примеру, когда мы выполняем команду ls, результат выполнения этой команды отправляется прямо в STDOUT (на экран терминала). Итак, если нам нужно получить вывод процесса, требуется доступ к потоку вывода. А это проще простого. В создании объекта ответа замените вызов file_get_contents() на $childProcess->stdout:


return new Response(
  200, ['Content-Type' => 'text/plain'], $childProcess->stdout
);

Все дочерние процессы имеют три свойства, которые относятся к stdio потокам: stdout, stdin, and stderr. В нашем случае, мы хотим отобразить вывод процесса на веб странице. Вместо строки в конструкторе класса Response в качестве третьего аргумента мы передаем поток. Класс Response достаточно умный, чтобы понять, что он получил поток и соответственно его обработать.


Итак, как обычно, перезагружаем сервер и смотрим, что же мы с вами накодили. Откроем в браузере страницу http://127.0.0.1:8080/: вы должны увидеть список файлов корневой папки проекта.



Последним шагом будет замена команды ls на что-то более полезное. Мы начали эту главу с отрисовки файла pages/index.html с помощью функции file_get_contents(). Теперь же мы можем прочитать этот файл абсолютно асинхронно, не беспокоясь о том, что это заблокирует наше приложение. Заменим команду ls на cat pages/index.html.


Если вы не знакомы с командой cat, то она используется для конкатенации и вывода файлов. Чаще всего эту команду используют для чтения файла и вывода его содержимого на стандартный поток вывода. Команда cat pages/index.html читает файл pages/index.html и выводит его содержимое на STDOUT. А мы с вами уже отправляем stdout в качестве тела ответа. Вот финальная версия файла routes.php:


// routes.php

use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\LoopInterface;
use React\ChildProcess\Process;
use React\Http\Response;

return [
  '/' => function (ServerRequestInterface $request, LoopInterface $loop) {
    $childProcess = new Process('cat pages/index.html');
    $childProcess->start($loop);

    return new Response(
      200, ['Content-Type' => 'text/html'], $childProcess->stdout
    );
  },
  '/upload' => function (ServerRequestInterface $request) {
    return new Response(
      200, ['Content-Type' => 'text/plain'], 'Upload page'
    );
  },
];

В итоге весь этот код нужен был лишь для того, чтобы заменить один вызов функции file_get_contents(). Инъекция зависимостей, передача объекта цикла событий, добавление дочерних процессов и работа с потоками. Все это лишь для того, чтобы заменить один вызов функции. Стоило ли оно того? Ответ: да, стоило. Когда что-то может заблокировать цикл событий, а файловая система определенно может, будьте уверены, что оно в итоге обязательно заблокирует, причем в самый неподходящий момент.


Создание дочернего процесса каждый раз, когда нам нужно обратиться к файловой системе, может выглядеть как лишние накладные расходы, которые повлияют на скорость и производительность нашего приложения. К сожалению, в PHP нет другого способа работать с файловой системой асинхронно. Все асинхронные PHP-библиотеки используют дочерние процессы (или расширения, которые их абстрагируют).


Читатели Хабры могут купить всю книгу со скидкой по этой ссылке.


А мы напоминаем, что всегда находимся в поиске крутых разработчиков! Приходите, у нас весело!

Skyeng 112,57
Компания
Поделиться публикацией
Комментарии 5
    0
    Сомнительный пример. Он не дает представления о том зачем вообще избегать блокирующих вызовов. Это как в учебниках про ООП где рассказывают о животных и зоопарке. Вроде все понятно а где и как применять в реальной жизни не понятно
      +1
      Программист пишет книгу и не знает, что текущая директория процесса вообще-то не обязана быть той же директорией, где лежит исполняемый файл?

      Если ваш пример запустят, условно говоря, вместо
      php ./index.php
      

      так:
      php ./public/index.php
      

      всё моментально сломается и починить это начинающему будет крайне сложно. Потому что вы ему не объяснили, почему нужно использовать абсолютные пути и как это правильно делать в PHP.

      Неиспользование __DIR__ — это вредительство, конечно. Зато асинхронный реакт, ага.
        0
        Спасибо за фидбэк, очень ценное замечание. Поправлю этот момент в след вересии книги.
          0
          __DIR__ тут не поможет, так, как зависимости мигрируют по файлам, и не факт что вызов кода произойдет в файле той же директории, что и стартовый файл. В такой ситуации только хардкор, только getcwd().
            0
            Поможет, не нужен хардкор) При создании дочерних процессов можно указать их Current working directory. Соответственно достаточно передать её в обработчик запроса:

            '/' => function (ServerRequestInterface $request, LoopInterface $loop, $cwd) {
                $childProcess = new Process('cat pages/index.html', $cwd);
                $childProcess->start($loop);
            
                return new Response(
                    200, ['Content-Type' => 'text/html'], $childProcess->stdout
                );
            }
            

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое