У нас в Skyeng работают весьма талантливые люди. Вот, например, бэкенд-разработчик Words Сергей Жук написал книгу про событийно-ориентированный PHP на ReactPHP, основанную на публикациях его блога. Книга англоязычная, мы решили перевести одну самодостаточную главу в надежде, что кому-то она сможет пригодиться. Ну и дать скидочную ссылку на всю работу.
В этой главе мы рассмотрим создание элементарного асинхронного сервера для видео-стриминга на ReactPHP Http Component. Это компонент высокого уровня, предоставляющий простой асинхронный интерфейс для обработки входящих соединений и HTTP-запросов.
Для поднятия сервера нам потребуются две вещи:
— инстанс сервера (ReactHttpServer) для обработки входящих запросов;
— сокет (ReactSocketServer) для обнаружения входящих соединений.
Для начала давайте сделаем очень простой Hello world сервер, чтобы понять, как все это работает.
use ReactSocketServer as SocketServer;
use ReactHttpServer;
use ReactHttpResponse;
use ReactEventLoopFactory;
use PsrHttpMessageServerRequestInterface;
// 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 worldn"
);
});
$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. Отлично!
Простой стриминг видео
Давайте теперь попробуем сделать что-нибудь поинтереснее. Конструктор ReactHttpResponse может принять читаемый поток (инстанс класса 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 ReactHttpServer;
use ReactHttpResponse;
use ReactEventLoopFactory;
use ReactStreamReadableResourceStream;
use PsrHttpMessageServerRequestInterface;
$loop = Factory::create();
$video = new ReadableResourceStream(
fopen('bunny.mp4', 'r'), $loop
);
$video->on('data', function(){
echo "Reading filen";
});
$server = new Server(
function (ServerRequestInterface $request) use ($stream) {
return new Response(
200, ['Content-Type' => 'video/mp4'], $stream
);
});
$socket = new ReactSocketServer('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 ReactHttpResponse;
use ReactEventLoopFactory;
use ReactEventLoopLoopInterface;
use ReactStreamReadableResourceStream;
use PsrHttpMessageServerRequestInterface;
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.
читатели могут купить книгу со скидкой 50%. У Сергея также есть полезный регулярно обновляемый англоязычный блог.
Наконец, напоминаем, что мы всегда находимся в поиске талантливых разработчиков! Приходите, у нас весело.
Автор: Ontaelio