📜 ⬆️ ⬇️

Create a server for streaming video: a chapter from a book on PHP from our developer



Very talented people work in Skyeng. For example, Words backend developer Sergei Zhuk wrote a book about event-oriented PHP based on ReactPHP, based on his blog’s publications. The book is in English, we decided to translate one self-sufficient chapter in the hope that it would 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 handling incoming connections and HTTP requests.
')


To raise the server we need two things:
- server instance (React \ Http \ Server) for processing incoming requests;
- socket (React \ Socket \ Server) for detecting incoming connections.

First, 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 is contained in a callback function that is passed to the server constructor. Callback is performed in response to each incoming request. It takes the instance of the Request object and returns a Response object. The constructor of the Response class accepts the response code, the headers and the response body. In our case, in response to each request, we return the same static line Hello world.

If we run this script, it will run indefinitely. A running server keeps track of incoming requests. If we open the address 127.0.0.1:8000 in our browser, we will see the string Hello world. Fine!



Simple streaming video


Let's now try to do something more interesting. The React \ Http \ Response constructor can accept a readable stream ( ReadableStreamInterface instance) as the response body, which allows us to transmit the data stream directly to the body. For example, we can open the bunny.mp4 file (you can download it from Github ) in read mode, create a ReadableResourseStream with it, 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 ReadableResponseStream instance, we need a cycle of events, we have to pass it to the closure. In addition, we changed the Content-Type header to video/mp4 , so that the browser understands that in response we send it a video.

You do not need to declare the Content-Length header, since ReactPHP automatically uses chunked transfer and sends the corresponding Transfer_Encoding: chunked 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 ReadableResourseStream instance directly in the server's callback function. Remember the asynchrony of our application. If we create a stream outside the callback and just pass it, 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 stream events. Each time a readable stream receives data from its source, it fires the data event. We can assign a handler to this event that will issue a message every time we read the data from the 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 starts to expect incoming requests, and at the same time we start reading the file.

Therefore, there is a possibility that by the time when the first request comes to the server, we have already 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 results in a blank browser page.



Improvements


Next we will try to improve our small server. Suppose we want to give the user the ability to specify the name of the file for streaming directly in the query string. For example, if you enter 127.0.0.1/?video=bunny.mp4 in the browser address bar, the server will start streaming the file bunny.mp4. We will store the streaming files in the media directory. Now we need to somehow get the parameters from the request. The request object, which we get in the request handler, contains the getQueryParams() method, which returns a GET array, similarly to the $_GET global variable:

 $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 video bunny.mp4, we have to go to 127.0.0.1:8000?video=bunny.mp4 . The server checks the incoming request for GET parameters. If we find the video parameter, we think that this is 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 response.

But there are problems. See them?

- What if there is no such file on the server? We must in this case return page 404.
- Now we have a fixed Content-Type value in the header. We need to define it in accordance with 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 him.

Check for file


Before opening the file and creating the stream, we need to check if the file exists on the server at all. 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 has requested the wrong file. We give the correct answer:



Determining the MIME Type of a File


PHP has a great mime_content_type() function 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 Content-Type value specified in the header:

 $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 ); }); 

Great, we removed the Content-Type value that is fixed in the header, now it is automatically determined according to the requested file.

Restriction on file requests


There is a problem with the request 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 such a request in the address bar of the browser: 127.0.0.1:8000/?video=../server.php , then the result will be the following:


Not very safe ... To fix this, we can use the basename() function to take only the file name from the request, cutting the file path if it was specified:

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

Now the same query will give 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 it or expand it, you can leave it so, 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 confused for understanding and support. Let's do a little refactoring, put the logic into our own VideoStreaming class. To be able to use this class as a call handler for the request, we need to embed the magic __invoke() method into it. After that, it will be enough for us to simply pass the instance of this class as a callback to the Server constructor:

 // ... $loop = Factory::create(); $videoStreaming = new VideoStreaming($loop); $server = new Server($videoStreaming); 

Now you can build a class VideoStreaming . It requires one dependency - the instance of the event loop, which will be embedded through the constructor To begin with, you can simply copy the code from the callback to the __invoke() method, and then do its refactoring:

 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 __invoke() method. Let's see what is happening 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, getFilePath() , is very simple. We get the request parameters using the $request->getQueryParams() method. If they do not have the file key, we simply return a simple string indicating that the user has opened the server with no 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 a 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 makeResponseFromFile() method is also very simple. If there is no file for the specified path, we immediately return a 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 complete code for the VideoStreaming class:

 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 the 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 looking for talented developers ! Come, we have fun.

Source: https://habr.com/ru/post/341306/


All Articles