Service Subscribers

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Because this service is instantiated on every request... it means that all four of the objects in its constructor also need to be instantiated:

... lines 1 - 21
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator
{
... lines 24 - 30
public function __construct(EntityManagerInterface $entityManager, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, UserPasswordEncoderInterface $passwordEncoder)
{
$this->entityManager = $entityManager;
$this->urlGenerator = $urlGenerator;
$this->csrfTokenManager = $csrfTokenManager;
$this->passwordEncoder = $passwordEncoder;
}
... lines 38 - 94
}

That's not a huge deal... except that two of these services probably wouldn't be instantiated during a normal request and aren't even used unless the current request is a login form submit. In other words, we're always instantiating these objects... even though we don't need them!

How can we fix this? By using a service subscriber: it's a strategy in Symfony that allows you to get a service you need... but delay its instantiation until - and unless - you actually need to use it. It's great for performance. But, like many things, it comes at a cost: a bit more complexity.

Implementing ServiceSubscriberInterface

Start by adding an interface to this class: ServiceSubscriberInterface:

... lines 1 - 21
use Symfony\Contracts\Service\ServiceSubscriberInterface;
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements ServiceSubscriberInterface
{
... lines 26 - 100
}

Then I'll move to the bottom of the file, go to the "Code"->"Generate" menu - or Command+N on a Mac - and select "Implement Methods" to generate the one method this interface requires: getSubscribedServices():

... lines 1 - 23
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements ServiceSubscriberInterface
{
... lines 26 - 91
public static function getSubscribedServices()
{
... lines 94 - 99
}
}

What does this return? An array of type-hints for all the services we need. For this class, it's these four. So, return EntityManagerInterface::class, UrlGeneratorInterface::class, CsrfTokenManagerInterface::class and OtherLongInterfaceName::class, uh, UserPasswordEncoderInterface::class:

... lines 1 - 23
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements ServiceSubscriberInterface
{
... lines 26 - 91
public static function getSubscribedServices()
{
return [
EntityManagerInterface::class,
UrlGeneratorInterface::class,
CsrfTokenManagerInterface::class,
UserPasswordEncoderInterface::class,
];
}
}

By doing this, we can now remove these four arguments. Replace them with ContainerInterface - the one from Psr\Container - $container:

... lines 1 - 6
use Psr\Container\ContainerInterface;
... lines 8 - 23
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements ServiceSubscriberInterface
{
... lines 26 - 29
public function __construct(ContainerInterface $container)
{
... line 32
}
... lines 34 - 100
}

When Symfony sees the new interface and this argument, it will pass us a, sort of, "mini-container" that holds the 4 objects we need. But it does this in a way where those 4 objects aren't created until we use them.

Finish this by removing the old properties... and having just one: $container. Set it with $this->container = $container:

... lines 1 - 23
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements ServiceSubscriberInterface
{
... lines 26 - 27
private $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
... lines 34 - 100
}

Using the Container Locator

Because those properties are gone, using the services looks a bit different. For example, down here for CsrfTokenManager, now we need to say $this->container->get() and pass it the type-hint CsrfTokenManagerInterface::class:

... lines 1 - 23
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements ServiceSubscriberInterface
{
... lines 26 - 55
public function getUser($credentials, UserProviderInterface $userProvider)
{
... line 58
if (!$this->container->get(CsrfTokenManagerInterface::class)->isTokenValid($token)) {
... line 60
}
... lines 62 - 70
}
... lines 72 - 100
}

This will work just like before except that the CsrfTokenManager won't be instantiated until this line is hit... and if this line isn't hit, it won't be instantiated.

For entityManager, use $this->container->get(EntityManagerInterface::class), for passwordEncoder, $this->container->get(UserPasswordEncoderInterface::class) and finally, for urlGenerator, use $this->container->get->(UrlGeneratorInterface::class). I'll copy that and use it again inside getLoginUrl():

... lines 1 - 23
class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements ServiceSubscriberInterface
{
... lines 26 - 55
public function getUser($credentials, UserProviderInterface $userProvider)
{
... line 58
if (!$this->container->get(CsrfTokenManagerInterface::class)->isTokenValid($token)) {
... line 60
}
$user = $this->container->get(EntityManagerInterface::class)->getRepository(User::class)->findOneBy(['email' => $credentials['email']]);
... lines 64 - 70
}
public function checkCredentials($credentials, UserInterface $user)
{
return $this->container->get(UserPasswordEncoderInterface::class)->isPasswordValid($user, $credentials['password']);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
{
... lines 80 - 83
return new RedirectResponse($this->container->get(UrlGeneratorInterface::class)->generate('app_homepage'));
}
protected function getLoginUrl()
{
return $this->container->get(UrlGeneratorInterface::class)->generate('app_login');
}
... lines 91 - 100
}

So, a little bit more complicated... but it should take less resources to create this class. The question is: did this make enough difference for us to want this added complexity? Let's find out. First, clear the cache:

php bin/console cache:clear

And warm it up:

php bin/console cache:warmup

Comparing the Results

Move back over... I'll close some tabs and... refresh. Profile again: I'll call this one: [Recording] Homepage service subscriber: https://bit.ly/sf-bf-service-subscriber. View the call graph.

Excellent! Go back to the "Memory" dimension and search for "login". The call is still here but it's taking a lot less memory and less time. Let's compare this to be sure though. Click back to the homepage and go from the previous profile to this one: https://bit.ly/sf-bf-service-subscriber-compare.

Nice! The wall time is down by 4%... CPU is down and memory also decreased... but just a little bit.

So was this change worth it? Probably. But this doesn't mean you should run around and use service subscribers everywhere. Why? Because they add complexity to your code and, unless you have a specific situation, it won't help much or at all. Use Blackfire to find the real problems and target those.

For example, we also could have made this same change to our AgreeToTermsSubscriber:

... lines 1 - 2
namespace App\EventSubscriber;
... lines 4 - 14
class AgreeToTermsSubscriber implements EventSubscriberInterface
{
private $security;
private $formFactory;
private $twig;
private $entrypointLookup;
public function __construct(Security $security, FormFactoryInterface $formFactory, Environment $twig, EntrypointLookupInterface $entrypointLookup)
{
$this->security = $security;
$this->formFactory = $formFactory;
$this->twig = $twig;
$this->entrypointLookup = $entrypointLookup;
}
public function onRequestEvent(RequestEvent $event)
{
$user = $this->security->getUser();
// only need this for authenticated users
if (!$user instanceof User) {
return;
}
// in reality, you would hardcode the most recent "terms" date
// change so you can see if the user needs to "re-agree". I've
// set it dynamically to 1 year ago to avoid anyone hitting
// this - as it's just example code...
//$latestTermsDate = new \DateTimeImmutable('2019-10-15');
$latestTermsDate = new \DateTimeImmutable('-1 year');
// user is up-to-date!
if ($user->getAgreedToTermsAt() >= $latestTermsDate) {
return;
}
$form = $this->formFactory->create(AgreeToUpdatedTermsFormType::class);
$html = $this->twig->render('main/agreeUpdatedTerms.html.twig', [
'form' => $form->createView()
]);
// resets Encore assets so they render correctly later
// only technically needed here because we should really
// "exit" this function before rendering the template if
// we know the user doesn't need to see the form!
$this->entrypointLookup->reset();
$response = new Response($html);
$event->setResponse($response);
}
public static function getSubscribedEvents()
{
return [
RequestEvent::class => 'onRequestEvent',
];
}
}

This class is also instantiated on every request... but rarely needs to do its work. That means we are causing the FormFactory object to be instantiated on every request.

But, go back to the latest profile... click to view the memory dimension... and search for "agree". There it is! It took 1.61 milliseconds and 41 kilobytes to instantiate this. That's... a lot less than the login authenticator. So, is making this class a service subscriber worth it? For me, no. I'd rather get back to writing features or fixing bigger performance issues.

Next, we can take a lot more control of the profiling process, like profiling just a portion of our code or automatically triggering a profile based on some condition, instead of needing to manually use the browser extension. Let's talk about the Blackfire SDK next.

Leave a comment!

  • 2020-04-30 weaverryan

    Awesome Holger Maerz! Glad it helped - they're a fun idea :).

    Cheers!

  • 2020-04-30 Holger Maerz

    Thank you. This solved some issues I had. Didn't know about Service Subscribers yet