Creating a server for streaming video: a chapter from a book on PHP from our developer
- Tutorial
We at Skyeng have very talented people. For example, Words backend developer Sergey Zhuk wrote a book about event-oriented PHP on ReactPHP, based on the publications of his blog. The book is English, we decided to translate one self-sufficient chapter in the hope that it could be useful to someone. Well, give a discount link to all the work.
In this chapter, we will look at creating an elementary asynchronous server for video streaming on the ReactPHP Http Component . This is a high-level component that provides a simple asynchronous interface for processing incoming connections and HTTP requests.
To raise the server, we need two things:
- the server instance (React \ Http \ Server) to process incoming requests;
- socket (React \ Socket \ Server) to detect incoming connections.
To get started, let's make a very simple Hello world server to understand how it all works.
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();
The main logic of this server lies in the callback function passed to the server constructor. A callback is made in response to each incoming request. It takes an instance of the object
Request
and returns the object Response
. The class constructor Response
accepts the response code, headers, and the response body. In our case, in response to each request, we return the same static string Hello world. If we run this script, it will run indefinitely. A running server monitors incoming requests. If we open the address 127.0.0.1:8000 in our browser, we will see the string Hello world. Excellent!
Simple video streaming
Let's now try to do something more interesting. The React \ Http \ Response constructor can accept a readable stream (class instance
ReadableStreamInterface
) as the response body, which allows us to transfer the data stream directly to the body. For example, we can open the bunny.mp4 file (it can be downloaded from Github ) in read mode, create a stream with it ReadableResourseStream
and provide this stream as the response body:$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
);
});
To create an instance,
ReadableResponseStream
we need an event loop, we must pass it to the closure. In addition, we changed the title Content-Type
to video/mp4
so that the browser understands that in the response we send him a video. The header
Content-Length
does not need to be declared, because ReactPHP automatically uses chunked transfer and sends the corresponding header Transfer_Encoding: chunked
. Let’s now refresh the browser window and watch the streaming video:
Super! We made a streaming video server with a few lines of code!
It is important to create an instance.
ReadableResourseStream
directly in the server callback function. Remember the asynchrony of our application. If we create a stream outside the callback and just pass it on, no streaming will happen. Why? Because the process of reading a video file and processing incoming server requests work asynchronously. This means that while the server is waiting for new connections, we also begin to read the video file. To verify this, we can use thread events. Each time a read stream receives data from its source, it fires an event
data
. We can assign a handler to this event that will display a message every time we read data from a file: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();
When the interpreter reaches the last line
$loop->run();
, the server begins to wait for incoming requests, and at the same time we begin to read the file. Therefore, it is likely that by the time the first request arrives on the server, we will have reached the end of the video file and we will not have data for streaming. When the request handler receives an already closed response stream, it simply sends an empty response body, which will lead to an empty browser page.
Enhancements
Further we will try to improve our small server. Suppose we want to give the user the ability to specify the file name for streaming directly in the query string. For example, if you enter 127.0.0.1/?video=bunny.mp4 in the address bar of your browser, the server will stream the bunny.mp4 file. We will store files for streaming in the media directory. Now we need to somehow get the parameters from the request. The request object that we receive in the request handler contains a method
getQueryParams()
that returns a GET array, similar to a global variable $_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
);
});
Now, to watch the bunny.mp4 video, we need to go to 127.0.0.1:8000?video=bunny.mp4 . The server checks the incoming request for GET parameters. If we find the parameter
video
, we consider this to be the name of the video file that the user wants to see. Then we build the path to this file, open the readable stream and pass it in the response. But there are problems. See them?
- What if the server does not have such a file? In this case, we must return page 404.
- Now we have a value fixed in the header
Content-Type
. We need to define it according to the specified file. - The user can request any file on the server. We must limit the request to only those files that we are ready to give to it.
Check file availability
Before opening a file and creating a stream, we need to check if this file even exists on the server. If not, return 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
);
});
Now our server will not crash if the user requests the wrong file. We give the correct answer:
Defining a MIME file type
PHP has a great function
mime_content_type()
that returns the MIME type of a file. With its help, we can determine the MIME type of the requested video file and replace it with the value specified in the header 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
);
});
Well, we removed the value
Content-Type
that was hard-coded in the header , now it is automatically determined according to the requested file.File Request Limit
There was a problem with requesting files. The user can specify any file on the server in the query string. For example, if the code of our server is in server.php and we specify this request in the address bar of the browser: 127.0.0.1:8000/?video=../server.php , then as a result we get the following:
Not very safe ... To fix this , we can use the function
basename()
to take only the file name from the request, cutting off the file path if it was specified:// ...
$filePath = __DIR__ . DIRECTORY_SEPARATOR
. 'media' . DIRECTORY_SEPARATOR . basename($file);
// ...
Now the same query will return page 404. Fixed!
Refactoring
In general, our server is already ready, but its main logic, located in the request handler, does not look very good. Of course, if you are not going to change or expand it, you can leave it this way directly in the callback. But if the server logic changes, for example, instead of plain text we want to build HTML pages, this callback will grow and quickly become too confusing to understand and support. Let's do a little refactoring, put the logic in our own class
VideoStreaming
. To be able to use this class as the called request handler, we must embed the magic method in it __invoke()
. After that, it will be enough for us to simply pass the instance of this class as a callback to the constructor Server
:// ...
$loop = Factory::create();
$videoStreaming = new VideoStreaming($loop);
$server = new Server($videoStreaming);
Now you can build a class
VideoStreaming
. It requires one dependency — an instance of an event loop that will be embedded through the constructor. To get started, you can simply copy the code from the request callback to the method __invoke()
, and then refactor it: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
);
}
}
Next we will refactor the method
__invoke()
. Let's see what happens here: 1. We parse the query string and determine which file the user needs.
2. Create a stream from this file and send it as an answer.
It turns out that we can distinguish two methods here:
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)
{
// ...
}
}
The first one
getFilePath()
is very simple. We get the request parameters using the method $request->getQueryParams()
. If there is no key in them file
, we simply return a simple string indicating that the user opened the server without GET parameters. In this case, we can show a static page or something like that. Here we return a simple text message Video streaming server. If the user specified file in the GET request, we create the path to this file and return it: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);
}
// ...
}
The method is
makeResponseFromFile()
also very simple. If there is no file on the specified path, we immediately return the 404 error. Otherwise, we open the requested file, create a readable stream and return it in the response body: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
);
}
}
Here is the entire VideoStreaming class code:
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);
}
}
Of course, instead of simply calling the request handler, we now have three times more code, but if this code changes in the future, it will be much easier for us to make these changes and support our application.
Examples from this chapter can be found on GitHub .
Sergey also has a useful regularly updated English-language blog .
Finally, we remind you that we are always in search of talented developers ! Come, we have fun.