У нас в 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.
У Сергея также есть полезный регулярно обновляемый англоязычный блог.
Наконец, напоминаем, что мы всегда находимся в поиске талантливых разработчиков! Приходите, у нас весело.