Buy

After a container is built, you should compile it:

24 lines dino_container/roar.php
... lines 1 - 10
$container = new ContainerBuilder();
... lines 12 - 16
$container->compile();
runApp($container);
... lines 19 - 24

This starts one final layer to the build process, which anyone can hook into to make final adjustments. For now, it's not doing anything - but it's really important inside the framework.

In a big project - parsing Yaml files and collecting all this service Definition stuff can start to take a lot of time. Our container is nice, but it's coming at a performance cost.

Let's see how much by adding some really basic profiling code. Up top, add a $startTime variable. And down below, figure out how much time elapsed, multiply it by 1000 to get microseconds, and while we're here, round it. And hey, let's use our container to get out the logger and debug a message about this:

29 lines dino_container/roar.php
... lines 1 - 8
$start = microtime(true);
... lines 10 - 19
runApp($container);
$elapsed = round((microtime(true) - $start) * 1000);
$container->get('logger')->debug('Elapsed Time: '.$elapsed.'ms');
... lines 24 - 29

So let's see how long this takes:

php dino_container/roar.php

37ms at first, but then it settles to about 19ms after running a few times. Not bad, but this is a tiny project. Just keep that 19ms number in mind.

Caching the Container

Here's the question: can we take all of this metadata about the container and cache it somehow? Absolutely - and the way it caches is incredible.

After compiling, create a new variable called $dumper and set it to a new PhpDumper object. Pass the $container to the dumper:

33 lines dino_container/roar.php
... lines 1 - 19
$container->compile();
$dumper = new PhpDumper($container);
... lines 22 - 33

This guy is an expert at taking that metadata and caching it to a file. To do that, use the good ol' fashioned file_put_contents - pass it some new file path - how about cached_container.php and for the contents, call $dumper->dump():

33 lines dino_container/roar.php
... lines 1 - 19
$container->compile();
$dumper = new PhpDumper($container);
file_put_contents(__DIR__.'/cached_container.php', $dumper->dump());
... lines 23 - 33

Let's see what this does! Run the script again:

php dino_container/roar.php

Now the cached_container.php file pops into existence. And it's awesome.

The Cached Container

Oh, so many good things to see. First, notice that this dumps a PHP class that extends Container:

142 lines dino_container/cached_container.php
... lines 1 - 3
use Symfony\Component\DependencyInjection\Container;
... lines 5 - 16
class ProjectServiceContainer extends Container
{
... lines 19 - 140
}

That's actually the same base class as the ContainerBuilder we've been working with, and it houses the all-important get() function that fetches out services. In other words, this ProjectServiceContainer looks and acts just like the $container we're using now.

Next, this has our two parameter values sitting on top. And if you call getParameter() to fetch one, it just uses this array:

142 lines dino_container/cached_container.php
... lines 1 - 16
class ProjectServiceContainer extends Container
{
private static $parameters = array(
'root_dir' => '/Users/weaverryan/Sites/knp/knpu-repos/symfony-journey-to-center/dino_container',
'logger_startup_message' => 'Logger just got started!!!',
);
... lines 23 - 100
public function getParameter($name)
{
$name = strtolower($name);
if (!(isset(self::$parameters[$name]) || array_key_exists($name, self::$parameters))) {
throw new InvalidArgumentException(sprintf('The parameter "%s" must be defined.', $name));
}
return self::$parameters[$name];
}
... lines 111 - 140
}

And now, the most important thing to notice: for each of our three services, there's a concrete method that's called when we ask for that service:

142 lines dino_container/cached_container.php
... lines 1 - 53
/**
* Gets the 'logger' service.
... lines 56 - 60
*/
protected function getLoggerService()
{
$this->services['logger'] = $instance = new \Monolog\Logger('main', array(0 => $this->get('logger.stream_handler')));
$instance->pushHandler($this->get('logger.std_out_handler'));
$instance->debug('Logger just got started!!!');
return $instance;
}
... line 71
/**
* Gets the 'logger.std_out_handler' service.
... lines 74 - 78
*/
protected function getLogger_StdOutHandlerService()
{
return $this->services['logger.std_out_handler'] = new \Monolog\Handler\StreamHandler('php://stdout');
}
... line 84
/**
* Gets the 'logger.stream_handler' service.
... lines 87 - 91
*/
protected function getLogger_StreamHandlerService()
{
return $this->services['logger.stream_handler'] = new \Monolog\Handler\StreamHandler('/Users/weaverryan/Sites/knp/knpu-repos/symfony-journey-to-center/dino_container/dino.log');
}
... lines 97 - 142

Seriously, if you look at the get() function in the parent class, you'll find that calling $container->get('logger.std_out_logger') will ultimately execute this getLogger_StdOutLoggerService() method.

And these methods use the exact PHP code we would write to instantiate these objects directly. We pass the container Definition objects, and it dumps the raw PHP code that those represent.

This is even more incredible when you look at the getLoggerService() method:

142 lines dino_container/cached_container.php
... lines 1 - 61
protected function getLoggerService()
{
$this->services['logger'] = $instance = new \Monolog\Logger('main', array(0 => $this->get('logger.stream_handler')));
$instance->pushHandler($this->get('logger.std_out_handler'));
$instance->debug('Logger just got started!!!');
return $instance;
}
... lines 71 - 142

Look closely: it creates the new Logger object, passes main and then passes an array, with a call to $this->get('logger.stream_handler') to fetch that service from itself - the container. The second arguments key in the Yaml file causes this.

Next, it has our two method calls: pushHandler() with $this->get('logger.std_out_logger') and then a call to debug(). Everything we put into those Definitions are dumped into a real PHP file that contains the raw code we would've written anyways.

So, if we use this container class directly, then fetching objects out of it could not be faster. Let's do it!

Using the Cached Container

Copy the path to the file and create a new $cachedContainer variable way up top before we even start with the ContainerBuilder. Our app now has two options: we can create the ContainerBuilder, load it up with the Definition config and then use it, OR, if that cached container is available, we can skip everything and just use it. After all. if we call get('logger') on it, it'll give us the exact same Logger.

So, if (!file_exists($cachedContainer)), then we do need to do all the building work to dump the container:

39 lines dino_container/roar.php
... lines 1 - 12
require __DIR__.'/../vendor/autoload.php';
$cachedContainer = __DIR__.'/cached_container.php';
if (!file_exists($cachedContainer)) {
$container = new ContainerBuilder();
$container->setParameter('root_dir', __DIR__);
$loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/config'));
$loader->load('services.yml');
$container->compile();
$dumper = new PhpDumper($container);
file_put_contents(__DIR__ . '/cached_container.php', $dumper->dump());
}
... lines 27 - 39

But one way or another, that file eventually exists. So if we require it, we can say $container = new \ProjectServiceContainer(), which is the class name used in the cache file:

39 lines dino_container/roar.php
... lines 1 - 14
$cachedContainer = __DIR__.'/cached_container.php';
if (!file_exists($cachedContainer)) {
... lines 17 - 25
}
require $cachedContainer;
$container = new \ProjectServiceContainer();
runApp($container);
... lines 31 - 39

We're still passing this $container into runApp(), and even though it's technically a different object, it's not going to make any difference. The only thing we need to change is that runApp() is type-hinted with ContainerBuilder. Well, it turns out that what we really need is Container, which is the base class for the builder and our cached class.

So I'll change the type-hint to Container. And we can go a step further: the Container class implements an interface called ContainerInterface:

39 lines dino_container/roar.php
... lines 1 - 6
use Symfony\Component\DependencyInjection\ContainerInterface;
... lines 8 - 34
function runApp(ContainerInterface $container)
{
$container->get('logger')->info('ROOOOAR');
}

Ok, try out the brand new cached container!

php dino_container/roar.php

It works! And woh - check out that elapsed time: 4ms, down from 19. If you delete the cached_container.php file, the next run takes 22ms because it needs to rebuild it. Then we're right back down to 4ms. This is one reason why Symfony is able to be so fast, even in big systems.

Now that you've got the real story of how container building works, let's see how things look inside Symfony.

Leave a comment!

  • 2016-04-29 weaverryan

    Hi Andrew!

    Wow, this is really interesting what you've done! The compile() step runs the "compiler pass" process (https://knpuniversity.com/scre... - a series of functions that make final changes to the container before it's dumped. So, as you saw, you can dump it before, but you're skipping these compiler pass functions.

    What do they do? Well, any bundle can add compiler passes, so it depends. But most *are* important. The most common thing a compiler pass does is process dependency injection tags. For example, if you created an event listener/subscriber by adding a service and tagging it with kernel.event_listener (or kernel.event_subscriber), that won't work until a compiler pass runs that processes that (it's called the RegisterListenersPass). If you want to see what other compiler passes are registered, they are added inside bundle classes - e.g. FrameworkBundle adds most of them to the system.

    My guess is that the faster response is due to the fact that these "extra" things aren't hooked up. But, you need them :). Probably, in a large application, the difference between a compiled and non-compiled container would *still* be quite small - like the 14ms you're seeing. So, if your app takes 250ms to load, you won't notice much difference.

    Anyways, very interesting detective work on this! I would not have expected a performance difference (the 14ms) of this size.

    Cheers!

  • 2016-04-29 Andrew Grudin

    If to compare var_export ($container) before
    $container->compile();
    and var_export ($container) after that ,
    we see a big difference. Last one became more puffy.
    What technically is the sense of ->compile() ?
    Is this step must be made just to prepare $container for consuming by new PhpDumper() and further dumping?

    From the other hand, look, if to comment this line out:

    $loader->load('services.yml');
    //$container->compile();
    $dumper = new PhpDumper($container);
    file_put_contents(__DIR__.'/cached_container.php', $dumper->dump());
    runApp($container);

    and then commit in bash: php dino_container/roar.php
    we get all the same nice cached_container.php file , but even quicker (24 ms versus 38ms).
    Could you clarify all this for me?