Search code examples
phpsymfonymiddlewaresilex

How to send multiple rendering from a controller in Silex?


In order to fix users waiting pain due to some (already optimized) DB calculations : about 3 to 10 seconds.

We need to make a waiting page during the long calculation process as every flights comparators do for example.

Our architecture is based on Silex 1.3.

What we want to achieve is:

  1. Trigger controller action by routing URL matching and start calculations
  2. Make a first render without modifying the request : twig waiting page
  3. At the end of calculations render the final twig with all data driven by calculations

I tested it with $app->before attribute bound to the route and returning/rendering stops the calculation...

So how to do that double rendering based on calculation?

NOT SUITABLE SOLUTION FOR OUR PURPOSE:

The first workaround I implemented is a JavaScript show/hide spinner element. But this is not a suitable solution because we really need to get a temporary rendering from server.

EDIT

The first render is working but I'm not enable to do the second rendering by calling my controller action (defined as controller as service) which is doing the rendering and I get this EXCEPTION:

RuntimeException: Accessed request service outside of request scope. Try moving that call to a before handler or controller.

Here is my controller definition:

 $app['index.controller'] = $app->share(function() use ($app) {
    return new IndexController($app);
});

Here is my route definition:

$app->get('/vue-ensemble/{city}', function (Request $request, Application $app)
{
    $content = function() use ($app) {
        $wait = $app->render('index_test.twig', array());
        $wait->send();
        flush();

        // Long process
        $process = $app['index.controller']->overviewAction();
        $process->send();
        flush();
    };

    return $app->stream($content);
});

Here is my controller action:

  protected $app;
    public function __construct(Application $app)
    {
        $this->app = $app;
    }

    public function overviewAction(){
          /* DO LONG PROCESS */
           return $this->app->render('overview.twig', array('some elements'=>'some values'));
        }

EDIT '

Unfortunately I still have the same issue, here is the stack trace:

    fatal error: Uncaught exception 'RuntimeException' with message 'Accessed request service outside of request scope. Try moving that call to a before handler or controller.' in C:\inforisq\application\vendor\silex\silex\src\Silex\Application.php on line 150
( ! ) RuntimeException: Accessed request service outside of request scope. Try moving that call to a before handler or controller. in C:\inforisq\application\vendor\silex\silex\src\Silex\Application.php on line 150
Call Stack
#   Time    Memory  Function    Location
1   0.0010  240848  {main}( )   ...\index.php:0
2   0.5250  4506656 Silex\Application->run( )   ...\index.php:14
3   0.7040  13098664    Symfony\Component\HttpFoundation\Response->send( )  ...\Application.php:564
4   0.7040  13101248    Symfony\Component\HttpFoundation\StreamedResponse->sendContent( )   ...\Response.php:372
5   0.7040  13101296    call_user_func:{C:\inforisq\application\vendor\symfony\http-foundation\StreamedResponse.php:90} ( ) ...\StreamedResponse.php:90
6   0.7040  13101384    {closure:C:\inforisq\application\app\config\routing.php:20-27}( )   ...\StreamedResponse.php:90
7   0.7040  13118168    Inforisq\Controller\IndexController->overviewAction( )  ...\routing.php:25
8   0.7040  13118328    Lib\InforisqApplication->place_analyzeURL( )    ...\IndexController.php:64
9   0.7060  13306184    Indicator\Repository\PlaceRepository->analyzeURLPlace( )    ...\PlaceTrait.php:23
10  0.7060  13306304    Lib\InforisqApplication->request( ) ...\PlaceRepository.php:423
11  0.7060  13306384    Pimple->offsetGet( )    ...\PlaceRepository.php:26
12  0.7060  13306464    Silex\Application->Silex\{closure}( )   ...\Pimple.php:83

Solution

  • You need to use a Symfony\Component\HttpFoundation\StreamedResponse

    see https://symfony.com/doc/current/components/http_foundation/introduction.html#streaming-a-response

    Edit

    When you return a response from a controller, the kernel calls $response->send(), but internally Response::send() calls Response::sendHeaders() then Response::sendContent().

    So sendHeaders() in this case will be send once by the kernel on the streamed response, then if you need other Response objects in your callback for convenience, you must call only sendContent().

    If you need to custom the http response code or the headers you can pass them as arguments in the method Application::stream($callback, $statusCode, array $headers).

    Before editing my answer I used flush() like the example in the symfony docs, but you may need some cache to be able to handle a second controller in the callback so first use ob_start() and ob_flush() for the "waiting" response.

    use \Silex\Application;
    use \Symfony\Component\HttpFoundation\Request;
    use \Symfony\Component\HttpFoundation\Response;
    
    $app->get('/vue-ensemble/{city}', function (Request $request, Application $app, $city)
    {
        /** @var \Acme\Controller\IndexController $indexController */
        $indexController = $app['index.controller'];
    
        /** @var Response $wait */
        $wait = $app->render('wait.html.twig', array('city' => $city);
    
        // Callback
        $content = function () use ($wait, $indexController) {
            ob_start();
            $wait->sendContent();
            ob_flush();
    
            $indexController->overviewAction($city)->sendContent();
            flush();
        }
    
        return $app->stream($content);
    
        // Will send a \Symfony\Component\HttpFoundation\StreamedResponse
        // equivalent to :
    
        // $streamedResponse = new StreamedResponse();
        // $streamedResponse->setCallback($content);
        //
        // return streamedResponse;
    });
    

    Edit 2:

    You should only pass the services you need in a constructor of service instead of pass the whole container each time :

    $app['index.controller'] = $app->share(function() use ($app) {
        return new IndexController($app['twig'], $app['some_helper']);
    });
    
    $app['some_helper'] = $app->protect(function($arg1, arg2) use ($app) {
        $helper = new \Acme\Helper($app['some_dependency'];
    
        return $helper->help(arg1, arg2);
    });
    

    Then :

    class IndexController
    {
        protected $twig;
        protected $helper;
    
        public function __construct(\Twig_Engine $twig, \Acme\Helper $helper)
        {
            $this->twig = $twig;
            $this->helper = $helper;
        }
    
        public function overview($city)
        {
            $some_arg = ...
    
            $viewArg = $this->helper($some_arg, $city);
    
            return $this->twig->renderResponse('overview.twig', array('some elements' => $viewArg));
        }
    }
    

    But then it would be better if the indexController just returned $this->twig->render(...) but then it should be echo $indexController->overview($city)