service and context

Service and context

Netpistols
7 minutes read

On one occasion, as part of a project for a client, I was tasked with building an API for our system from the ground up. A major part of it were methods that exported data to partner services – letting some of our information outside as a result. As is usually the case with APIs – whatever is released, it’s not to be touched. And as the system grows, a need to modify its API and publish one version after another remains (and will remain!). Out of other options, I had to find a way to organize the code so that the whole procedure is as simple and painless as possible – without the need to copy tons of code. As I worked my way through it, I put a lot of effort into reusing the logic of the application in as many places as I could.

Eventually I came up with a simple idea, which saved me a lot of time and made it easier to maintain the code – to make the services dependent on the context. Anything could serve as such a context, be it the API version, session parameter, or user settings… as long as it’s is a string or a number. Such approach results in code as follows:

$context = ... // any given context
$service = $contextAwareContainer->get($context, 'my.service');

This produces an object of a class defined for a given context. We can define many contexts for the very same service. Another assumption is the ability to define dependencies based on the context.

To achieve this, we can make use of the powerful mechanisms of service tagging and container compiling (the classes that implement CompilerPassInterface).

We can use tags to mark every service we wish to make context-dependent. Here is an example declaration of such a service:

app.context_sensitive.a.processor:
  class: AppBundleProcessorProcessorA
  tags:
    - { name: context.sensitive, context: a, alias: processor }

app.context_sensitive.b.processor:
  class: AppBundleProcessorProcessorB
  tags:
    - { name: context.sensitive, context: b, alias: processor }

app.context_sensitive.ab.processor.universal:
  class: AppBundleProcessorProcessorAB
  tags:
    - { name: context.sensitive, context: a, alias: processor.universal }- { name: context.sensitive, context: b, alias: processor.universal }

In this example context.sensitive is the name of our tag. That tag accepts 2 arguments: context and alias. Alias groups together services that perform the same task. We choose one of them based on the value of the context parameter. Hence, back to the example above, if we retrieve a service named processor in the context of a, an object of the ProcessorA class is created. Similarly, for the b context the resulting object is ProcessorB. In the case of the processor.universal alias, the result is ProcessorAB for both the a and b context.

For starters, we need a class that delivers each service when needed. We can call it, say, ContextAwareContainer. Let’s define its interface so that we can implement it in other ways in the future.

interface ContextAwareContainerInterface
{
  public function get($context, $alias);
  public function set($context, $alias, $serviceId);
}

An example of a class that implements this interface can be found below:

class ContextAwareContainer implements ContextAwareContainerInterface, Initializable
{
  protected $container;
  protected $services;

  private $initialized;

  public function __construct(ContainerInterface $container){$this->container = $container;
    $this->services = [];
    $this->initialized = false;
  }

  public function get($context, $alias){$serviceId = $this->getServiceId($context, $alias);
    return $this->container->get($serviceId);
  }

  protected function getServiceId($context, $alias)
  {
    if (!isset($this->services[$context])) {
      throw new UndefinedContextException($context);
    }

    if (!isset($this->services[$context][$alias])) {
      throw new ServiceNotFoundInContextException($alias, $context);
    }

    return $this->services[$context][$alias];
  }

  public function set($context, $alias, $serviceId){
    if ($this->isInitialized()) {
      throw new AlreadyInitializedException;
    }

    if (isset($this->services[$context][$alias])) {
      throw new AlreadyRegisteredException($alias, $context);
    }
 
    $this->services[$context][$alias] = $serviceId;
  }

  public function initialize(){
    if ($this->isInitialized()) {
      throw new AlreadyInitializedException;
    }

    $this->initialized = true;
  }

  public function isInitialized(){
    return $this->initialized;
  }
}

And its definition as a service:

app.container.context_aware:
  class: AppBundleContextAwareContainerContextAwareContainer
  arguments:
    - @service_container

The concept is pretty straightforward: the ContextAwareContainer is a service that holds a reference to the container. The inner array $services contains the IDs of services we reference through context and alias.

The set(…) method builds the array (avoiding duplicates) and the get(…) method returns a service based on context and alias, passed as arguments.

We can also create and implement the Initializable interface, which prevents more services from being registered after the initial initialization [the initialize() and isInitialized() methods].

This implementation makes use of the whole container (which generally is considered a bad idea) in order to take advantage of the built in service lazy-loading: unless we explicitly request a service, it won’t be created. This way we save on both time and memory.

The last step is to create an object that implements the CompilerPassInterface interface. It’s job is to find the services we tagged and save their data in the ContextAwareContainer.

class ContextSensitiveServicesCompilerPass implements CompilerPassInterface
{
  const CONTEXT_AWARE_CONTAINER_ID = 'app.container.context_aware';

  public function process(ContainerBuilder $container){
    if (!$container->has(self::CONTEXT_AWARE_CONTAINER_ID)) {
      return;
    }
 
    $definition = $container->findDefinition(self::CONTEXT_AWARE_CONTAINER_ID);

    $taggedServices = $container->findTaggedServiceIds('context.sensitive');
 
    foreach ($taggedServices as $serviceId => $tags) {
      foreach ($tags as $attributes) {$definition->addMethodCall('set',
          [$attributes['context'], $attributes['alias'], $serviceId]);
      }
    }

    $definition->addMethodCall('initialize');
  }
}

The findTaggedServiceIds(…) method accepts an array, the indices of which are the IDs of services that have the context.sensitive tag, and its values are the attributes of those tags. All the data is used as arguments of the set(…) method of the container.

It is worth noticing that all the actions performed by the process(…) method of the CompilerPassInterface object take place before the container is actually created and cause the definitions of the services to be modified, not their instances. We can take advantage of it by affecting other services, e.g. by adding the context.sensitive tag to vendor-defined services (that is, defined by external libraries) or creating dependencies dynamically.

At this point we have already fullfilled our basic assumptions. With the ContextAwareContainer, we can select services based on the context.

But now consider a situation where we have no choice but to make our service dependent on another service and the class of this dependency changes with the context.

To achieve this, we have to find a way to pass the alias of the dependency to the definition of the service. One way to do this is as follows:

app.context_sensitive.ab.processor.dependant:
  class: AppBundleProcessorProcessorABDependant
  properties:
    dynamic_arguments:
      a: [processor]
      b: [processor]
  tags:
    - { name: context.sensitive, context: a, alias: processor.dependant }- { name: context.sensitive, context: b, alias: processor.dependant }

The properties attribute is a multidimensional array that accepts any type of value. We use it to pass our dependencies. The dynamic_arguments is an array. Its keys are contexts and its values are an array of aliases. We want to set it up as follows: when we retrieve the processor.dependant service with the a context, it is passed a reference to a service with the processor alias and the a context (that is an object of the ProcessorA class) as its dependency. However, if the context equals b, a service of the ProcessorB class is injected into an object of the ProcessorABDependant class.

Let’s modify the ContextAwareInterface so that it stores the information on the dynamic dependencies.

class ContextAwareContainer implements ContextAwareContainerInterface, Initializable
{/** @var array */
  protected $arguments;
 
  // …

  public function set($context, $alias, $serviceId, array $arguments)
  {
    if ($this->isInitialized()) {
      throw new AlreadyInitializedException;
    }

    if (isset($this->services[$context][$alias])) {
      throw new AlreadyRegisteredException($alias, $context);
    }

    $this->services[$context][$alias] = $serviceId;
 
    foreach ($arguments as $argContext => $argServiceAlias) {
      if ($argContext == $context) {$this->arguments[$serviceId][$argContext] = $argServiceAlias;
      }
    }
  }
}

We expand the set(…) function so that It accepts the $arguments array that contains our context-related dependencies. We save the aliases in the inner array called $arguments as well. From now on they can be referenced with the ID of the parent service and context.

The next step is to have the service accept the dependencies we defined. In order to so, let’s define a simple interface called DynamicArgumentsInterface:

interface DynamicArgumentsInterface
{
  public function setDynamicArguments(array $arguments);
}

An example of an implementation for the ProcessorABDependant class:

public function setDynamicArguments(array $arguments)
{$this->processor = $arguments[0];
}

Now we can modify the get(…) function in the ContextAwareContainer, so that it can pass the dependencies we defined to a service we want to retrieve:

class ContextAwareContainer implements ContextAwareContainerInterface, Initializable
{

  // …
 
  public function get($context, $alias){$serviceId = $this->getServiceId($context, $alias);

    $service = $this->container->get($serviceId);
 
    if ($service instanceof DynamicArgumentsInterface) {
      if (!isset($this->arguments[$serviceId][$context])) {
        throw new DynamicArgumentsMissingException($alias, $context);
      }
 
      $arguments = [];
 
      foreach ($this->arguments[$serviceId][$context] as $argServiceId) {$arguments[] = $this->container->get($this->getServiceId($context, $argServiceId));
      }
 
      $service->setDynamicArguments($arguments);
    }

    return $service;
  }
}

No changes to the very beginning of the function – we start with retrieving a service of our choice. Next, provided that it implements the DynamicArgumentsInterface we created, we get all of the dependencies (do not forget that the $arguments array only stores the alias of a given service!) and pass them as an array to the setDynamicArguments(…) function. If it’s not the case, we make no further steps – the logic remains the same.

The final step is to have the container compiler read all of the dynamic arguments and pass them to the ContextAwareContainer. Let’s expand the ContextSensitiveServicesCompilerPass to reflect this:

class ContextSensitiveServicesCompilerPass implements CompilerPassInterface
{
 
  // …

  public function process(ContainerBuilder $container)
  {
    if (!$container->has(self::CONTEXT_AWARE_CONTAINER_ID)) {
      return;
    }

    $definition = $container->findDefinition(self::CONTEXT_AWARE_CONTAINER_ID);
 
    $taggedServices = $container->findTaggedServiceIds('context.sensitive');
 
    foreach ($taggedServices as $serviceId => $tags) {$serviceDefinition = $container->findDefinition($serviceId);
      $properties = $serviceDefinition->getProperties();
      $arguments = isset($properties['dynamic_arguments']) 
                 ? $properties['dynamic_arguments']: [];

      foreach ($tags as $attributes) {$definition->addMethodCall('set',
          [$attributes['context'], $attributes['alias'], $serviceId, $arguments]);
      }
    }

    $definition->addMethodCall('initialize');
  }
}

Before we register the calling of the set(…) method, we have to retrieve the properties option of the service that is currently being processed, read its dynamic_arguments parameter and add it as the last argument of the set(…) method.

This is how we go about defining dependencies between services that automatically adjust to a given context.

With just a little bit of effort and a few relatively simple classes, we managed to create a functionality that expands on what can be achieved with the Dependency Injection component. The result is convenient and straightforward to use and perhaps it will help you solve a problem or achieve an even higher level of abstraction in your application.

Do you think there is a better way to solve the problem described in this article, one that is more elegant and flexible? I bet you can! Symfony2 is an enormous framework and it is a true challenge to learn all of the ways it can help you. I discover new aspects of the framework every day and I hope that soon enough I will find an even better solution.

If one of you, Dear Readers, have any ideas or comments, I welcome you to contact me or propose changes on your own in the form of pull requests to my repo. I am more than eager to learn something new from your experience!

The code from this article can be found at: https://github.com/michalkurzeja/ContextAwareContainerDemo

On-demand webinar: Moving Forward From Legacy Systems

We’ll walk you through how to think about an upgrade, refactor, or migration project to your codebase. By the end of this webinar, you’ll have a step-by-step plan to move away from the legacy system.

moving forward from legacy systems - webinar

Latest blog posts

Ready to talk about your project?

1.

Tell us more

Fill out a quick form describing your needs. You can always add details later on and we’ll reply within a day!

2.

Strategic Planning

We go through recommended tools, technologies and frameworks that best fit the challenges you face.

3.

Workshop Kickoff

Once we arrange the formalities, you can meet your Polcode team members and we’ll begin developing your next project.