Book Image

Extending Symfony2 Web Application Framework

By : Sebastien Armand
Book Image

Extending Symfony2 Web Application Framework

By: Sebastien Armand

Overview of this book

Table of Contents (13 chapters)

Services


A service is just a specific instance of a given class. For example, whenever you access doctrine such as $this->get('doctrine'); in a controller, it implies that you are accessing a service. This service is an instance of the Doctrine EntityManager class, but you never have to create this instance yourself. The code needed to create this entity manager is actually not that simple since it requires a connection to the database, some other configurations, and so on. Without this service already being defined, you would have to create this instance in your own code. Maybe you will have to repeat this initialization in each controller, thus making your application messier and harder to maintain.

Some of the default services present in Symfony2 are as follows:

  • The annotation reader

  • Assetic—the asset management library

  • The event dispatcher

  • The form widgets and form factory

  • The Symfony2 Kernel and HttpKernel

  • Monolog—the logging library

  • The router

  • Twig—the templating engine

It is very easy to create new services because of the Symfony2 framework. If we have a controller that has started to become quite messy with long code, a good way to refactor it and make it simpler will be to move some of the code to services. We have described all these services starting with "the" and a singular noun. This is because most of the time, services will be singleton objects where a single instance is needed.

A geolocation service

In this example, we imagine an application for listing events, which we will call "meetups". The controller makes it so that we can first retrieve the current user's IP address, use it as basic information to retrieve the user's location, and only display meetups within 50 kms of distance to the user's current location. Currently, the code is all set up in the controller. As it is, the controller is not actually that long yet, it has a single method and the whole class is around 50 lines of code. However, when you start to add more code, to only list the type of meetups that are the user's favorites or the ones they attended the most. When you want to mix that information and have complex calculations as to which meetups might be the most relevant to this specific user, the code could easily grow out of control!

There are many ways to refactor this simple example. The geocoding logic can just be put in a separate method for now, and this will be a good step, but let's plan for the future and move some of the logic to the services where it belongs. Our current code is as follows:

use Geocoder\HttpAdapter\CurlHttpAdapter;
use Geocoder\Geocoder;
use Geocoder\Provider\FreeGeoIpProvider;

public function indexAction()
  {

Initialize our geocoding tools (based on the excellent geocoding library at http://geocoder-php.org/) using the following code:

    $adapter = new CurlHttpAdapter();
    $geocoder = new Geocoder();
    $geocoder->registerProviders(array(
      new FreeGeoIpProvider($adapter),
    ));

Retrieve our user's IP address using the following code:

    $ip = $this->get('request')->getClientIp();
    // Or use a default one
    if ($ip == '127.0.0.1') {
      $ip = '114.247.144.250';
    }

Get the coordinates and adapt them using the following code so that they are roughly a square of 50 kms on each side:

    $result = $geocoder->geocode($ip);
    $lat = $result->getLatitude();
    $long = $result->getLongitude();
    $lat_max = $lat + 0.25; // (Roughly 25km)
    $lat_min = $lat - 0.25;
    $long_max = $long + 0.3; // (Roughly 25km)
    $long_min = $long - 0.3;

Create a query based on all this information using the following code:

    $em = $this->getDoctrine()->getManager();
    $qb = $em->createQueryBuilder();
    $qb->select('e')
        ->from('KhepinBookBundle:Meetup, 'e')
        ->where('e.latitude < :lat_max')
        ->andWhere('e.latitude > :lat_min')
        ->andWhere('e.longitude < :long_max')
        ->andWhere('e.longitude > :long_min')
        ->setParameters([
          'lat_max' => $lat_max,
          'lat_min' => $lat_min,
          'long_max' => $long_max,
          'long_min' => $long_min
        ]);

Retrieve the results and pass them to the template using the following code:

    $meetups = $qb->getQuery()->execute();
    return ['ip' => $ip, 'result' => $result,
      'meetups' => $meetups];
  }

The first thing we want to do is get rid of the geocoding initialization. It would be great to have all of this taken care of automatically and we would just access the geocoder with: $this->get('geocoder');.

Tip

Downloading the example code

You can download the example code files for all Packt books you have purchased from your account at http://www.packtpub.com. If you purchased this book elsewhere, you can visit http://www.packtpub.com/support and register to have the files e-mailed directly to you.

You can define your services directly in the config.yml file of Symfony under the services key, as follows:

    services:
      geocoder:
        class: Geocoder\Geocoder

That is it! We defined a service that can now be accessed in any of our controllers. Our code now looks as follows:

// Create the geocoding class
$adapter = new \Geocoder\HttpAdapter\CurlHttpAdapter();
$geocoder = $this->get('geocoder');
$geocoder->registerProviders(array(
    new \Geocoder\Provider\FreeGeoIpProvider($adapter),
));

Well, I can see you rolling your eyes, thinking that it is not really helping so far. That's because initializing the geocoder is a bit more complex than just using the new \Geocoder\Geocoder() code. It needs another class to be instantiated and then passed as a parameter to a method. The good news is that we can do all of this in our service definition by modifying it as follows:

services:
    # Defines the adapter class
    geocoder_adapter:
        class: Geocoder\HttpAdapter\CurlHttpAdapter
        public: false
    # Defines the provider class
    geocoder_provider:
        class: Geocoder\Provider\FreeGeoIpProvider
        public: false
        # The provider class is passed the adapter as an argument
        arguments: [@geocoder_adapter]
    geocoder:
        class: Geocoder\Geocoder
        # We call a method on the geocoder after initialization to set up the
        # right parameters
        calls:
            - [registerProviders, [[@geocoder_provider]]]

It's a bit longer than this, but it is the code that we never have to write anywhere else ever again. A few things to notice are as follows:

  • We actually defined three services, as our geocoder requires two other classes to be instantiated.

  • We used @+service_name to pass a reference to a service as an argument to another service.

  • We can do more than just defining new Class($argument); we can also call a method on the class after it is instantiated. It is even possible to set properties directly when they are declared as public.

  • We marked the first two services as private. This means that they won't be accessible in our controllers. They can, however, be used by the Dependency Injection Container (DIC) to be injected into other services.

Our code now looks as follows:

// Retrieve current user's IP address
$ip = $this->get('request')->getClientIp();

// Or use a default one
if ($ip == '127.0.0.1') {
    $ip = '114.247.144.250';
}
// Find the user's coordinates
$result = $this->get('geocoder')->geocode($ip);
$lat = $result->getLatitude();
// ... Remaining code is unchanged

Note

Here, our controllers are extending the BaseController class, which has access to DIC since it implements the ContainerAware interface. All calls to $this->get('service_name') are proxied to the container that constructs (if needed) and returns the service.

Let's go one step further and define our own class that will directly get the user's IP address and return an array of maximum and minimum longitude and latitudes. We will create the following class:

namespace Khepin\BookBundle\Geo;

use Geocoder\Geocoder;
use Symfony\Component\HttpFoundation\Request;

class UserLocator {

    protected $geocoder;

    protected $user_ip;

    public function __construct(Geocoder $geocoder, Request $request) {
        $this->geocoder = $geocoder;
        $this->user_ip = $request->getClientIp();
        if ($this->user_ip == '127.0.0.1') {
            $this->user_ip = '114.247.144.250';
        }
    }

    public function getUserGeoBoundaries($precision = 0.3) {
        // Find the user's coordinates
        $result = $this->geocoder->geocode($this->user_ip);
        $lat = $result->getLatitude();
        $long = $result->getLongitude();
        $lat_max = $lat + 0.25; // (Roughly 25km)
        $lat_min = $lat - 0.25;
        $long_max = $long + 0.3; // (Roughly 25km)
        $long_min = $long - 0.3;
        return ['lat_max' => $lat_max, 'lat_min' => $lat_min,
           'long_max' => $long_max, 'long_min' => $long_min];
    }
}

It takes our geocoder and request variables as arguments, and then does all the heavy work we were doing in the controller at the beginning of the chapter. Just as we did before, we will define this class as a service, as follows, so that it becomes very easy to access from within the controllers:

# config.yml
services:
    #...
    user_locator:
       class: Khepin\BookBundle\Geo\UserLocator
       scope: request
       arguments: [@geocoder, @request]

Notice that we have defined the scope here. The DIC has two scopes by default: container and prototype, to which the framework also adds a third one named request. The following table shows their differences:

Scope

Differences

Container

All calls to $this->get('service_name') return the same instance of the service.

Prototype

Each call to $this->get('service_name') returns a new instance of the service.

Request

Each call to $this->get('service_name') returns the same instance of the service within a request. Symfony can have subrequests (such as including a controller in Twig).

Now, the advantage is that the service knows everything it needs by itself, but it also becomes unusable in contexts where there are no requests. If we wanted to create a command that gets all users' last-connected IP address and sends them a newsletter of the meetups around them on the weekend, this design would prevent us from using the Khepin\BookBundle\Geo\UserLocator class to do so.

Note

As we see, by default, the services are in the container scope, which means they will only be instantiated once and then reused, therefore implementing the singleton pattern. It is also important to note that the DIC does not create all the services immediately, but only on demand. If your code in a different controller never tries to access the user_locator service, then that service and all the other ones it depends on (geocoder, geocoder_provider, and geocoder_adapter) will never be created.

Also, remember that the configuration from the config.yml is cached when on a production environment, so there is also little to no overhead in defining these services.

Our controller looks a lot simpler now and is as follows:

$boundaries = $this->get('user_locator')->getUserGeoBoundaries();
// Create our database query
$em = $this->getDoctrine()->getManager();
$qb = $em->createQueryBuilder();
$qb->select('e')
    ->from('KhepinBookBundle:Meetup', 'e')
    ->where('e.latitude < :lat_max')
    ->andWhere('e.latitude > :lat_min')
    ->andWhere('e.longitude < :long_max')
    ->andWhere('e.longitude > :long_min')
    ->setParameters($boundaries);
// Retrieve interesting meetups
$meetups = $qb->getQuery()->execute();
return ['meetups' => $meetups];

The longest part here is the doctrine query, which we could easily put on the repository class to further simplify our controller.

As we just saw, defining and creating services in Symfony2 is fairly easy and inexpensive. We created our own UserLocator class, made it a service, and saw that it can depend on our other services such as @geocoder service. We are not finished with services or the DIC as they are the underlying part of almost everything related to extending Symfony2. We will keep seeing them throughout this book; therefore, it is important to have a good understanding of them before continuing.

Testing services and testing with services

One of the great advantages of putting your code in a service is that a service is just a simple PHP class. This makes it very easy to unit test. You don't actually need the controller or the DIC. All you need is to create mocks of a geocoder and request class.

In the test folder of the bundle, we can add a Geo folder where we test our UserLocator class. Since we are only testing a simple PHP class, we don't need to use WebTestCase. The standard PHPUnit_Framework_TestCase will suffice. Our class has only one method that geocodes an IP address and returns a set of coordinates based on the required precision. We can mock the geocoder to return fixed numbers and therefore avoid a network call that would slow down our tests. A simple test case looks as follows:

    class UserLocatorTest extends PHPUnit_Framework_TestCase
    {
        public function testGetBoundaries()
        {
            $geocoder = $this->getMock('Geocoder\Geocoder');
            $result = $this->getMock('Geocoder\Result\Geocoded');

            $geocoder->expects($this->any())->method('geocode')->will($this->returnValue($result));
            $result->expects($this->any())->method('getLatitude')->will($this->returnValue(3));
            $result->expects($this->any())->method('getLongitude')->will($this->returnValue(7));

            $request = $this->getMock('Symfony\Component\HttpFoundation\Request', ['getUserIp']);
            $locator = new UserLocator($geocoder, $request);

            $boundaries = $locator->getUserGeoBoundaries(0);

            $this->assertTrue($boundaries['lat_min'] == 3);
        }
    }

We can now simply verify that our class itself is working, but what about the whole controller logic?

We can write a simple integration test for this controller and test for the presence and absence of some meetups on the rendered page. However, in some cases, for performance, convenience, or because it is simply not possible, we don't want to actually call the external services while testing. In that case, it is also possible to mock the services that will be used in the controller. In your tests, you will need to do the following:

public function testIndexMock()
{
    $client = static::createClient();
    $locator = $this->getMockBuilder('Khepin\BookBundle\Geo\UserLocator')->disableOriginalConstructor()->getMock();
    $boundaries = ["lat_max" => 40.2289, "lat_min" => 39.6289, "long_max" => 116.6883, "long_min" => 116.0883];
    $locator->expects($this->any())->method('getUserGeoBoundaries')->will($this->returnValue($boundaries));
    $client->getContainer()->set('user_locator', $locator);
    $crawler = $client->request('GET', '/');
  // Verify that the page contains the meetups we expect
    
}

Here, we mock the UserLocator class so that it will always return the same coordinates. This way, we can better control what we are testing and avoid waiting for a long call to the geolocation server.

Tagging services

You have most likely already encountered tagged services when using Symfony, for example, if you have defined custom form widgets or security voters. Event listeners, which we will talk about in the second part of this chapter, are also tagged services.

In our previous examples, we created a user_locator service that relies on a geocoder service. However, there are many possible ways to locate a user. We can have their address information in their profile, which will be faster and more accurate than getting it from a user's IP address. We can use different online providers such as FreeGeoIp as we did in the previous code, or have a local geoip database. We can even have all of these in our application at the same time, and try them one after the other from most to least accurate.

Let's define the interface for this new type of geocoder as follows:

namespace Khepin\BookBundle\Geo;

interface Geocoder
{
    public function getAccuracy();

    public function geocode($ip);
}

We will then define two geocoders using the following code; the first one just wraps our existing one in a new class that implements our Geocoder interface:

namespace Khepin\BookBundle\Geo;
use Geocoder\Geocoder as IpGeocoder;

class FreeGeoIpGeocoder implements Geocoder
{
    public function __construct(IpGeocoder $geocoder)
    {
        $this->geocoder = $geocoder;
    }

    public function geocode($ip)
    {
        return $this->geocoder->geocode($ip);
    }

    public function getAccuracy()
    {
        return 100;
    }
}

The first type of geocoder is configured as follows:

freegeoip_geocoder:
    class: Khepin\BookBundle\Geo\FreeGeoIpGeocoder
    arguments: [@geocoder]

The second geocoder returns a random location every time, as follows:

namespace Khepin\BookBundle\Geo;

class RandomLocationGeocoder implements Geocoder
{
    public function geocode($ip)
    {
        return new Result();
    }

    public function getAccuracy()
    {
        return 0;
    }
}

class Result
{
    public function getLatitude()
    {
        return rand(-85, 85);
    }

    public function getLongitude()
    {
        return rand(-180, 180);
    }

    public function getCountryCode()
    {
        return 'CN';
    }
}

The second geocoder is configured as follows:

random_geocoder:
    class: Khepin\BookBundle\Geo\RandomLocationGeocoder

Now, if we change the configuration of our user_locator service to use any of these geocoders, things will work correctly. However, what we really want is that it has access to all the available geolocation methods and then picks the most accurate one, even when we add new ones without changing the user_locator service.

Let's tag our services by modifying their configuration to add a tag as follows:

freegeoip_geocoder:
    class: Khepin\BookBundle\Geo\FreeGeoIpGeocoder
    arguments: [@geocoder]
    tags:
        - { name: khepin_book.geocoder }
random_geocoder:
    class: Khepin\BookBundle\Geo\RandomLocationGeocoder
    tags:
        - { name: khepin_book.geocoder }

We cannot pass all of these in the constructor of our class directly, so we'll modify our UserLocator class to have an addGeocoder method as follows:

class UserLocator
{    protected $geocoders = [];

    protected $user_ip;

    // Removed the geocoder from here
    public function __construct(Request $request)
    {
        $this->user_ip = $request->getClientIp();
    }

    public function addGeocoder(Geocoder $geocoder)
    {
        $this->geocoders[] = $geocoder;
    }

    // Picks the most accurate geocoder
    public function getBestGeocoder(){/* ... */}

    // ...
}

Informing the DIC that we want to add tagged services cannot be done only through configuration. This is instead done through a compiler pass when the DIC is being compiled.

Compiler passes allow you to dynamically modify service definitions. They can be used for tagged services and for creating bundles that enable extra functionalities whenever another bundle is also present and configured. The compiler pass can be used as follows:

namespace Khepin\BookBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Reference;

class UserLocatorPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
      if (!$container->hasDefinition('khepin_book.user_locator'))
        {
            return;
        }

        $service_definition = $container->getDefinition('khepin_book.user_locator');
        $tagged = $container->findTaggedServiceIds('khepin_book.geocoder');

        foreach ($tagged as $id => $attrs) {
            $service_definition->addMethodCall(
              'addGeocoder', 
              [new Reference($id)]
            );
        }
    }
}

After we have confirmed that the user_locator (renamed here as khepin_book.user_locator) service exists, we find all the services with the corresponding tag and modify the service definition for khepin_book.user_locator so that it loads these services.

Note

You can define custom attributes on a tag. So, we could have moved the accuracy of each geocoder to the configuration as follows, and then used the compiler pass to only provide the most accurate geocoder to the user locator:

tags:
    - { name: khepin_book.geocoder, accuracy: 69 }

Whenever we define the YAML configuration for services, Symfony will internally create service definitions based on that information. By adding a compiler pass, we can modify these service definitions dynamically. The service definitions are then all cached so that we don't have to compile the container again.