Pull to refresh
306.04
Skyeng
Крутейшая edtech-команда страны. Удаленная работа

Создаем сервер для потокового видео: глава из книги по PHP от нашего разработчика

Reading time 10 min
Views 40K


У нас в Skyeng работают весьма талантливые люди. Вот, например, бэкенд-разработчик Words Сергей Жук написал книгу про событийно-ориентированный PHP на ReactPHP, основанную на публикациях его блога. Книга англоязычная, мы решили перевести одну самодостаточную главу в надежде, что кому-то она сможет пригодиться. Ну и дать скидочную ссылку на всю работу.


В этой главе мы рассмотрим создание элементарного асинхронного сервера для видео-стриминга на ReactPHP Http Component. Это компонент высокого уровня, предоставляющий простой асинхронный интерфейс для обработки входящих соединений и HTTP-запросов.



Для поднятия сервера нам потребуются две вещи:
— инстанс сервера (React\Http\Server) для обработки входящих запросов;
— сокет (React\Socket\Server) для обнаружения входящих соединений.

Для начала давайте сделаем очень простой Hello world сервер, чтобы понять, как все это работает.

use React\Socket\Server as SocketServer; 
use React\Http\Server; 
use React\Http\Response; 
use React\EventLoop\Factory; 
use Psr\Http\Message\ServerRequestInterface; 

// init the event loop 
$loop = Factory::create(); 

// set up the components 
$server = new Server( 
   function (ServerRequestInterface $request) { 
     return new Response( 
       200, ['Content-Type' => 'text/plain'], "Hello world\n" 
     ); 
}); 

$socket = new SocketServer('127.0.0.1:8000', $loop); 

$server->listen($socket); 

echo 'Listening on ' 
  . str_replace('tcp:', 'http:', $socket->getAddress()) 
  . "\n"; 

// run the application 
$loop->run();

Основная логика этого сервера заключена в функции обратного вызова, передающейся конструктору сервера. Обратный вызов осуществляется в ответ на каждый входящий запрос. Он принимает инстанс объекта Request и возвращает объект Response. Конструктор класса Response принимает код ответа, заголовки и тело ответа. В нашем случае в ответ на каждый запрос мы возвращаем одну и ту же статическую строчку Hello world.

Если мы запустим этот скрипт, он будет выполняться бесконечно. Работающий сервер отслеживает входящие запросы. Если мы откроем адрес 127.0.0.1:8000 в нашем браузере, мы увидим строку Hello world. Отлично!



Простой стриминг видео


Давайте теперь попробуем сделать что-нибудь поинтереснее. Конструктор React\Http\Response может принять читаемый поток (инстанс класса ReadableStreamInterface) в качестве тела ответа, что позволяет нам передавать поток данных непосредственно в тело. Например, мы можем открыть файл bunny.mp4 (его можно скачать с Github) в режиме чтения, создать с ним поток ReadableResourseStream и предоставить этот поток в качестве тела ответа:

$server = new Server( 
   function (ServerRequestInterface $request) use ($loop) { 
     $video = new ReadableResourceStream( 
       fopen('bunny.mp4', 'r'), $loop 
     ); 

     return new Response( 
       200, ['Content-Type' => 'video/mp4'], $video 
     ); 
}); 

Для создания инстанса ReadableResponseStream нам нужен цикл событий, мы должны передать его в замыкание. Кроме того, мы поменяли заголовок Content-Type на video/mp4, чтобы браузер понимал, что в ответе мы посылаем ему видео.

Заголовок Content-Length объявлять не нужно, поскольку ReactPHP автоматически использует chunked transfer и отправляет соответствующий заголовок Transfer_Encoding: chunked.

Давайте теперь обновим окно браузера и посмотрим потоковое видео:



Супер! Мы сделали стриминговый видео-сервер с помощью нескольких строк кода!

Важно создать инстанс ReadableResourseStream непосредственно в функции обратного вызова сервера. Помните об асинхронности нашего приложения. Если мы создадим поток вне обратного вызова и просто передадим его, никакого стриминга не случится. Почему? Потому что процесс чтения видеофайла и обработка входящих запросов сервера работают асинхронно. Это значит, что пока сервер ждет новые соединения мы также начинаем читать видеофайл.

Чтобы убедиться в этом, мы можем использовать события потока. Каждый раз, когда читаемый поток получает данные из своего источника, он запускает событие data. Мы можем присвоить этому событию обработчик, который будет выдавать сообщение каждый раз, когда мы читаем данные из файла:

use React\Http\Server; 
use React\Http\Response; 
use React\EventLoop\Factory; 
use React\Stream\ReadableResourceStream; 
use Psr\Http\Message\ServerRequestInterface; 

$loop = Factory::create(); 

$video = new ReadableResourceStream( 
   fopen('bunny.mp4', 'r'), $loop 
); 

$video->on('data', function(){ 
  echo "Reading file\n"; 
}); 

$server = new Server( 
   function (ServerRequestInterface $request) use ($stream) { 
     return new Response( 
       200, ['Content-Type' => 'video/mp4'], $stream 
     ); 
}); 
$socket = new \React\Socket\Server('127.0.0.1:8000', $loop); 

$server->listen($socket); 

echo 'Listening on ' 
   . str_replace('tcp:', 'http:', $socket->getAddress()) 
   . "\n"; 

$loop->run();

Когда интерпретатор доходит до последней строки $loop->run();, сервер начинает ожидать входящие запросы, и одновременно мы начинаем читать файл.

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



Улучшения


Дальше мы попробуем улучшить наш маленький сервер. Допустим, мы хотим дать пользователю возможность указывать имя файла для стриминга непосредственно в строке запроса. Например, при вводе в адресной строке браузере 127.0.0.1/?video=bunny.mp4 сервер начнет стримить файл bunny.mp4. Хранить файлы для стриминга мы будем в директории media. Теперь нам надо каким-то образом получить параметры из запроса. Объект запроса, который мы получаем в обработчике запроса, содержит метод getQueryParams(), возвращающий массив GET, аналогично глобальной переменной $_GET:

$server = new Server( 
   function (ServerRequestInterface $request) use ($loop) { 
     $params = $request->getQueryParams(); 
     $file = $params['video'] ?? ''; 

     if (empty($file)) { 
       return new Response( 
          200, 
          ['Content-Type' => 'text/plain'], 
          'Video streaming server' 
        ); 
     } 

  $filePath = __DIR__ . DIRECTORY_SEPARATOR 
     . 'media' . DIRECTORY_SEPARATOR . $file; 

  $video = new ReadableResourceStream( 
     fopen($filePath, 'r'), $loop 
   ); 

  return new Response( 
     200, ['Content-Type' => 'video/mp4'], $video 
   ); 
}); 

Теперь, чтобы посмотреть видео bunny.mp4, мы должны зайти на 127.0.0.1:8000?video=bunny.mp4. Сервер проверяет входящий запрос на параметры GET. Если мы находим параметр video, мы считаем, что это название видеофайла, который хочет увидеть пользователь. Затем мы выстраиваем путь к этому файлу, открываем читаемый поток и передаем его в ответе.

Но здесь есть проблемы. Видите их?

— Что если на сервере нет такого файла? Мы должны в этом случае вернуть страницу 404.
— Теперь у нас есть жестко заданное в заголовке значение Content-Type. Нам надо определять его в соответствии с указанным файлом.
— Пользователь может запросить любой файл на сервере. Мы должны ограничить запрос только теми файлами, которые мы готовы ему отдать.

Проверка наличия файла


Прежде чем открыть файл и создать поток, мы должны проверить, существует ли вообще этот файл на сервере. Если нет – возвращаем 404:

$server = new Server( 
   function (ServerRequestInterface $request) use ($loop) { 
     $params = $request->getQueryParams(); 
     $file = $params['video'] ?? ''; 

     if (empty($file)) { 
       return new Response( 
         200, 
          ['Content-Type' => 'text/plain'], 
          'Video streaming server' 
       ); 
     } 

     $filePath = __DIR__ . DIRECTORY_SEPARATOR 
       . 'media' . DIRECTORY_SEPARATOR . $file; 

     if (!file_exists($filePath)) { 
       return new Response( 
         404, 
          ['Content-Type' => 'text/plain'], 
          "Video $file doesn't exist on server." 
       ); 
     } 

     $video = new ReadableResourceStream( 
       fopen($filePath, 'r'), $loop 
     ); 

     return new Response( 
       200, ['Content-Type' => 'video/mp4'], $video 
     ); 
});

Теперь наш сервер не будет падать, если пользователь запросил неверный файл. Мы выдаем правильный ответ:



Определение MIME-типа файла


В PHP есть отличная функция mime_content_type(), возвращающая MIME-тип файла. С ее помощью мы можем определить MIME-тип запрошенного видеофайла и заменить им заданное в заголовке значение Content-Type:

$server = new Server( 
   function (ServerRequestInterface $request) use ($loop) { 
     $params = $request->getQueryParams(); 
     $file = $params['video'] ?? ''; 

     if (empty($file)) { 
       return new Response( 
         200, 
          ['Content-Type' => 'text/plain'], 
          'Video streaming server' 
       ); 
     } 

     if (!file_exists($filePath)) { 
       return new Response( 
         404, 
          ['Content-Type' => 'text/plain'], 
          "Video $file doesn't exist on server." 
       ); 
     } 

     $video = new ReadableResourceStream( 
       fopen($filePath, 'r'), $loop 
     ); 

     $type = mime_content_type($filePath); 

     return new Response( 
       200, ['Content-Type' => $type], $video 
     ); 
}); 

Отлично, мы убрали жестко заданное в заголовке значение Content-Type, теперь оно определяется автоматически в соответствии с запрошенным файлом.

Ограничение на запрос файлов


Осталась проблема с запросом файлов. Пользователь может задать любой файл на сервере в строке запроса. Например, если код нашего сервера находится в server.php и мы укажем такой запрос в адресной строке браузера: 127.0.0.1:8000/?video=../server.php, то в результате получим следующее:


Не очень безопасно… Чтобы это исправить, мы можем использовать функцию basename(), чтобы брать только имя файла из запроса, отрезая путь к файлу, если он был указан:

// ... 

$filePath = __DIR__ . DIRECTORY_SEPARATOR 
   . 'media' . DIRECTORY_SEPARATOR . basename($file); 

// ... 

Теперь тот же запрос выдаст страницу 404. Исправлено!

Рефакторинг


Вообще, наш сервер уже готов, но его основная логика, размещенная в обработчике запроса, выглядит не очень. Разумеется, если вы не собираетесь ее менять или расширять, можно оставить и так, непосредственно в обратном вызове. Но если логика сервера будет меняться, например, вместо простого текста мы захотим строить HTML-страницы, этот обратный вызов будет расти и быстро станет слишком путаным для понимания и поддержки. Давайте сделаем небольшой рефакторинг, вынесем логику в собственный класс VideoStreaming. Чтобы иметь возможность использовать этот класс в качестве вызываемого обработчика запроса, мы должны встроить в него волшебный метод __invoke(). После этого нам будет достаточно просто передать инстанс этого класса в качестве обратного вызова конструктору Server:

// ... 

$loop = Factory::create(); 

$videoStreaming = new VideoStreaming($loop); 

$server = new Server($videoStreaming); 

Теперь можно строить класс VideoStreaming. Он требует одну зависимость – инстанс цикла событий, который будет встроен через конструктор. Для начала можно просто скопировать код из обратного вызова запроса в метод __invoke(), а затем заняться его рефакторингом:

class VideoStreaming 
{ 
  // ... 

  /** 
    * @param ServerRequestInterface $request 
    * @return Response 
    */ 
   function __invoke(ServerRequestInterface $request) 
   { 
     $params = $request->getQueryParams(); 
     $file = $params['video'] ?? ''; 

     if (empty($file)) { 
       return new Response( 
         200, 
          ['Content-Type' => 'text/plain'], 
          'Video streaming server' 
       ); 
     } 

     $filePath = __DIR__ . DIRECTORY_SEPARATOR 
       . 'media' . DIRECTORY_SEPARATOR . basename($file); 

     if (!file_exists($filePath)) { 
       return new Response( 
         404, 
          ['Content-Type' => 'text/plain'], 
          "Video $file doesn't exist on server." 
       ); 
     } 

     $video = new ReadableResourceStream( 
       fopen($filePath, 'r'), $this->eventLoop 
     ); 

     $type = mime_content_type($filePath); 

     return new Response( 
       200, ['Content-Type' => $type], $video 
     ); 
   } 
} 

Далее мы будем рефакторить метод __invoke(). Давайте разберемся, что тут происходит:
1. Мы парсим строку запроса и определяем, какой файл нужен пользователю.
2. Создаем поток из этого файла и отправляем его в качестве ответа.

Получается, мы можем здесь выделить два метода:

class VideoStreaming 
{ 
  // ... 

  /** 
    * @param ServerRequestInterface $request 
    * @return Response 
    */ 
   function __invoke(ServerRequestInterface $request) 
   { 
     $file = $this->getFilePath($request); 

     if (empty($file)) { 
       return new Response( 
         200, 
          ['Content-Type' => 'text/plain'], 
          'Video streaming server' 
       ); 
     } 

     return $this->makeResponseFromFile($file); 
   } 

  /** 
    * @param ServerRequestInterface $request 
    * @return string 
    */ 
  protected function getFilePath(ServerRequestInterface $request) 
   { 
     // ... 
   } 

  /** 

    * @param string $filePath 
    * @return Response 
    */ 
  protected function makeResponseFromFile($filePath) 
   { 
     // ... 
   } 
} 

Первый, getFilePath(), очень прост. Мы получаем параметры запроса с помощью метода $request->getQueryParams(). Если в них нет ключа file, мы просто возвращаем простую строку, показывающую, что пользователь открыл сервер без параметров GET. В этом случае мы можем показать статичную страницу или что-то в этом духе. Здесь мы возвращаем простое текстовое сообщение Video streaming server. Если пользователь указал file в запросе GET, мы создаем путь к этому файлу и возвращаем его:

class VideoStreaming 
{ 
  // ... 

  /** 
    * @param ServerRequestInterface $request 
    * @return string 
    */ 
  protected function getFilePath(ServerRequestInterface $request) 
   { 
     $file = $request->getQueryParams()['file'] ?? ''; 

     if (empty($file)) return ''; 

     return __DIR__ . DIRECTORY_SEPARATOR 
       . 'media' . DIRECTORY_SEPARATOR . basename($file); 
   } 

  // ... 
} 

Метод makeResponseFromFile() также очень прост. Если по указанному пути нет файла, мы сразу же возвращаем ошибку 404. В противном случае мы открываем запрошенный файл, создаем читаемый поток и возвращаем его в теле ответа:

class VideoStreaming 
{ 
  // ... 

  /** 
    * @param string $filePath 
    * @return Response 
    */ 
  protected function makeResponseFromFile($filePath) 
   { 
     if (!file_exists($filePath)) { 
       return new Response( 
         404, 
          ['Content-Type' => 'text/plain'], 
          "Video $filePath doesn't exist on server." 
       ); 
     } 

     $stream = new ReadableResourceStream( 
       fopen($filePath, 'r'), $this->eventLoop 
     ); 

     $type = mime_content_type($filePath); 

     return new Response( 
       200, ['Content-Type' => $type],  $stream 
     ); 
   } 
} 

Вот код класса VideoStreaming целиком:

use React\Http\Response; 
use React\EventLoop\Factory; 
use React\EventLoop\LoopInterface; 
use React\Stream\ReadableResourceStream; 
use Psr\Http\Message\ServerRequestInterface; 

class VideoStreaming 
{ 
  /** 
    * @var LoopInterface 
    */ 
  protected $eventLoop; 

  /** 
    * @param LoopInterface $eventLoop 
    */ 
  public function __construct(LoopInterface $eventLoop) 
   { 
     $this->eventLoop = $eventLoop; 
   } 

  /** 
    * @param ServerRequestInterface $request 
    * @return Response 
    */ 
   function __invoke(ServerRequestInterface $request) 
   { 
     $file = $this->getFilePath($request); 

     if (empty($file)) { 
       return new Response( 
         200, 
          ['Content-Type' => 'text/plain'], 
          'Video streaming server' 
       ); 
     } 

     return $this->makeResponseFromFile($file); 
  } 

  /** 
    * @param string $filePath 
    * @return Response 
    */ 
  protected function makeResponseFromFile($filePath) 
   { 
     if (!file_exists($filePath)) { 
       return new Response( 
         404, 
          ['Content-Type' => 'text/plain'], 
          "Video $filePath doesn't exist on server." 
       ); 
     } 

     $stream = new ReadableResourceStream( 
       fopen($filePath, 'r'), $this->eventLoop 
     ); 

     $type =  mime_content_type($filePath); 

     return new Response( 
       200, ['Content-Type' => $type], $stream 
     ); 
   } 

  /** 
    * @param ServerRequestInterface $request 
    * @return string 
    */ 
  protected function getFilePath(ServerRequestInterface $request) 
   { 
     $file = $request->getQueryParams()['file'] ?? ''; 

     if (empty($file)) return ''; 

     return __DIR__ . DIRECTORY_SEPARATOR 
         . 'media' . DIRECTORY_SEPARATOR . basename($file); 
   } 
} 

Разумеется, вместо простого обратного вызова обработчика запроса у нас теперь в три раза больше кода, но если этот код будет меняться в будущем, нам будет значительно проще проводить эти изменения и поддерживать наше приложение.

Примеры из этой главы можно найти на GitHub.

У Сергея также есть полезный регулярно обновляемый англоязычный блог.

Наконец, напоминаем, что мы всегда находимся в поиске талантливых разработчиков! Приходите, у нас весело.
Tags:
Hubs:
+19
Comments 12
Comments Comments 12

Articles

Information

Website
www.skyeng.team
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия
Representative
Alisa Kruglova