У нас в 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.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();
, сервер начинает ожидать входящие запросы, и одновременно мы начинаем читать файл.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
);
});
video
, мы считаем, что это название видеофайла, который хочет увидеть пользователь. Затем мы выстраиваем путь к этому файлу, открываем читаемый поток и передаем его в ответе. 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'
);
}
$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_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
, теперь оно определяется автоматически в соответствии с запрошенным файлом.basename()
, чтобы брать только имя файла из запроса, отрезая путь к файлу, если он был указан:// ...
$filePath = __DIR__ . DIRECTORY_SEPARATOR
. 'media' . DIRECTORY_SEPARATOR . basename($file);
// ...
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()
. Давайте разберемся, что тут происходит: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
);
}
}
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);
}
}
К сожалению, не доступен сервер mySQL