Drupal 9: Nghiên cứu lần nữa Services And Dependency Injection

30th Jun 2022
Table of contents

Drupal 8 and 9 are built upon services, with many parts of the system available through dependency injection, so it's important to understand the concepts. Services are a way to wrap objects and use dependency injection to produce a common interface. They are powerful and are used all over Drupal to do pretty much everything.

They can, however, be a little difficult for newcomers to the system to understand, especially if they are coming from Drupal 7 or other non-object oriented systems. When you look at some Drupal source code you are likely to see objects being created out of apparent thin air. It's a little hard to know where they come from if you aren't used to the how they work.

I first came across services when I started using Drupal 8 and it took me a little while to get my head around what they are and what they do. Before I understood them, I saw a lot of people online attempting to help by just pointing people to one service or another using this sort of construct.

$thing = \Drupal::service('thing');

This is helpful if you are familiar with Drupal services, but if you aren't then this doesn't tell you much. It is also bad practice to use this construct in certain situations, which I'll let into later on. If you have seen that construct around the internet but don't know what it means then I hope to clear things up a little.

I actually gave this article as a talk at DrupalCamp London 2018, but I have found myself referring to the slides quite often since then. I thought I would write it up as a couple of articles. Since I gave that talk around Drupal 8 I have updated the examples to be in line with Drupal 9.

Let's start with using Drupal services.

Using Drupal Services

The good news is that using Drupal services is pretty simple. Indeed, most of the complexity of services is deliberately hidden away from you. This allows you to get on with the work at hand without having to worry about where to get this or that object from and what parameters its constructor needs.

There are many different services in Drupal, that govern everything. If you want access to configuration, the internal cron system, path and routing, the rendering process, translations, queues, cache and even date calculations then you can use a Drupal service to do that. I have just mentioned a handful here, but there are plenty more services available in Drupal 9.

A good example of a service that is often used is the alias manager service. This service wraps the Drupal\path_alias\AliasManager class in Drupal and allows developers access to find an alias for a given path. This means that given a path like "/node/123" you can translate this to an alias in the form of "/page/some-page". This is useful if you have the node ID and want to find the correct path to the node so you can print it out. There are other ways to do this, especially if you have the full Node object, but this is used outside of that situation.

The service can be used the in following way. We use the \Drupal::service() method to get an instantiated AliasManager object and then use a function in that object to translate the path to the alias.

$aliasManager = \Drupal::service('path_alias.manager');
$path = '/node/123';
$alias = $aliasManager->getAliasByPath($path);

As the service returns an object we can chain together the method calls and do the alias lookup in one line, like this.

$path = '/node/123';
$alias = \Drupal::service('path_alias.manager')->getAliasByPath($path);

This does exactly the same thing as the above example, but in a single line of code

If you are writing code in Drupal it is also good practice to include docblock comments around this line so that your IDE can translate what type of object the $aliasManager variable contains.

/* @var \Drupal\path_alias\AliasManager $aliasManager */
$aliasManager = \Drupal::service('path_alias.manager');

When you start writing code your IDE will how print out a list of the methods you have access through, via the service object. Having this in place really helps you tap into the full functionality of the service and will absolutely speed up your development. This is an example of this working in PHPStorm.

This is an example of this working in PHPStorm.

Using docblock comments in PHPStorm to show the methods inside a Drupal service object.

Before you go off and start using this construct in all of your custom Drupal classes you should know that the above code should only really be used in static methods, hooks and preprocess functions in your theme. This is because you can use Drupal to inject services into your objects, whereas this isn't possible with static methods and stand alone functions. I will address this again later in the article.

Where To Find Services In Drupal

Services are all defined in YML files within Drupal. Every module that wants to define a service needs to create a file with the name of [module name].services.yml. This means that if we want to find services we just need to search the Drupal codebase within files that end in services.yml.

The path_alias.manager service I looked at earlier is defined in the file path_alias.services.yml, along with a few other alias based services. Since I have already shown how this works let's look at the service footprint.

The path_alias.manager service is defined in the following way.

  path_alias.manager:
    class: Drupal\path_alias\AliasManager
    arguments: ['@path_alias.repository', '@path_alias.whitelist', '@language_manager', '@cache.data']

The first line here is the name of the service and is used to ask Drupal to instantiate it, in this case the name is path_alias.manager.

The second line tells Drupal where to find the class it needs to instantiate. This points to a namespace of the class, rather than the filename, but it tells us that this class is in source directory of the path_alias module.

The final line consists of an array of four arguments. These arguments are an optional parameter that tell Drupal what arguments the constructor of the AliasManager requires. If we look at the constructor of the AliasManager class we can see that it requires four parameters, which map from the list of arguments to the parameters of the constructor.

class AliasManager implements AliasManagerInterface {
  /**
   * Constructs an AliasManager.
   *
   * @param \Drupal\path_alias\AliasRepositoryInterface $alias_repository
   *   The path alias repository.
   * @param \Drupal\path_alias\AliasWhitelistInterface $whitelist
   *   The whitelist implementation to use.
   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
   *   The language manager.
   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
   *   Cache backend.
   */
  public function __construct($alias_repository, AliasWhitelistInterface $whitelist, LanguageManagerInterface $language_manager, CacheBackendInterface $cache) {
    $this->pathAliasRepository = $alias_repository;
    $this->languageManager = $language_manager;
    $this->whitelist = $whitelist;
    $this->cache = $cache;
  }
}

The @ symbol in the argument list above denotes that these arguments are other services. There are actually different types of arguments we can use here.

'@path_alias.repository' - This is a reference to another service. So in this case we are referencing the path_alias.repository, which is defined in the same services file. If you see this structure around the Drupal codebase you can find the service it referenced by searching for "path_alias.repository:" (i.e. with a trailing colon) in any services.yml file.

'%app.root%' - This is a configuration item. Some of these are set by Drupal internally (like app.root) but you can also inject configuration settings in this way. This tends to be used less often but it's an option if you want to inject a setting directly into the class.

'value' - This is a literal variable, so the a string of 'value' will be passed as an argument. You can also use numeric and boolean values here so you can pass values like 123 or true.

By knowing where to find services in Drupal you already know how to get access to the numerous different types of services that Drupal offers. There are a number of other options that are available when setting up a service class, but this is the minimum required. You should know that not all entries in services.yml files are pure services as there are a few other constructs that can be added to these files. You can create cache bins or event listeners though this interface and although they are created like services they shouldn't be created outside of Drupal's control.

Why Use Dependency Injection?

Dependency injection within Drupal is automated dependency injection. This means that with a few rules in a settings file we can create objects and have the dependencies automatically injected into it without having to create them manually. In the Drupal codebase, the Symfony component DependencyInjection manages the dependencies. If you use Symfony then you might find a lot of familiarity in how Drupal manages dependencies.

But why use dependency injection? Couldn't we just create the objects we need and figure things out when we need them? Let's look at creating the AliasManager object without using any dependency injection.

Starting off with the AliasManager class, we know that it needs four parameters passed to it, so let's create that as a starting point where we create the AliasManager object.

use Drupal\path_alias\AliasManager;

$aliasManager = new AliasManager($alias_repository, $whitelist, $language_manager, $cache);

Of course, this doesn't work as we haven't defined any of the parameters, so start with the $alias_repository parameter. This is actually a reference to another service called path_alias.repository, which has it's own entry in the path_alias.services.yml file. The object we need to create here is called AliasRepository, so let's put the footprint of that object into the code.

use Drupal\path_alias\AliasManager;
use Drupal\path_alias\AliasRepository;

$alias_repository = new AliasRepository($connection)
$aliasManager = new AliasManager($alias_repository, $whitelist, $language_manager, $cache);

The AliasRepository constructor takes one parameter, which is a database connection. Thankfully, there exists a database factory that we can use to get a connection to the default database. Adding this to our code finishes the first parameter.

use Drupal\path_alias\AliasManager;
use Drupal\path_alias\AliasRepository;
use Drupal\Core\Database\Database;

$connection = Database::getConnection();
$alias_repository = new AliasRepository($connection);
$aliasManager = new AliasManager($alias_repository, $whitelist, $language_manager, $cache);

Moving onto the $whitelist parameter, this is also a service called path_alias.whitelist, also found in the path_alias.services.yml file. This service points to a class called AliasWhitelist. The constructor for this class takes 5 parameters. Adding the footprint of that object to our code we now have this.

use Drupal\path_alias\AliasManager;
use Drupal\path_alias\AliasRepository;
use Drupal\Core\Database\Database;
use Drupal\path_alias\AliasWhitelist;

$connection = Database::getConnection();
$alias_repository = new AliasRepository($connection);

$whitelist = new AliasWhitelist($cid, $cache, $lock, $state, $alias_repository);

$aliasManager = new AliasManager($alias_repository, $whitelist, $language_manager, $cache);

The next step is to start creating the other parameters for the AliasWhitelist object. The first is easy as this is just a string passed to the object to setup the cache identifier.

use Drupal\path_alias\AliasManager;
use Drupal\path_alias\AliasRepository;
use Drupal\Core\Database\Database;
use Drupal\path_alias\AliasWhitelist;

$connection = Database::getConnection();
$alias_repository = new AliasRepository($connection);

$cid = 'path_alias_whitelist';
$whitelist = new AliasWhitelist($cid, $cache, $lock, $state, $alias_repository);

$aliasManager = new AliasManager($alias_repository, $whitelist, $language_manager, $cache);

After this it starts getting complicated. The second parameter to the AliasWhitelist constructor is a Drupal cache object, which we can create using the built in CacheFactory object, which we also pass a couple of parameters to in order to create it.

use Drupal\path_alias\AliasManager;
use Drupal\path_alias\AliasRepository;
use Drupal\Core\Database\Database;
use Drupal\path_alias\AliasWhitelist;

$connection = Database::getConnection();
$alias_repository = new AliasRepository($connection);

$settings = Drupal\Core\Site\Settings::getInstance();
$default_bin_backends = $container->getParameter('cache_default_bin_backends');
$cacheFactory = new CacheFactory($settings, $default_bin_backends);

$cid = 'path_alias_whitelist';
$cache = $cacheFactory->get('bootstrap');
$lock = null;
$alias_repository = null;
$whitelist = new AliasWhitelist($cid, $cache, $lock, $state, $alias_repository);

$aliasManager = new AliasManager($alias_repository, $whitelist, $language_manager, $cache);

I'm already lost. I have written lots of code, I have more than 10 source code files open in my IDE, and I still haven't even finished creating the second parameter. There are another two parameters to create both of which have equally complex dependencies, and I haven't even gotten close to using the getAliasByPath() method.

What's worse is that I have already hard coded the database and configuration we are using as well as the configuration setup. If I go further I would need to also hard code other parameters and options into the code. Making these decisions means that it would be very hard to change this code in the future. If the AliasManager class changed in the future I would spend hours re-writing this code to make it work again. If this seems far fetched then remember that the path_alias.manager service used to be called path.alias_manager in Drupal 8, and this change also changed the underlying classes used by the service.

Compare all of that complexity with using the dependency injection method. We would take more than 50 lines of code and reduce this down to just a single line.

$path = '/node/123';
$alias = \Drupal::service('path_alias.manager')->getAliasByPath($path);

This is far easier to read and understand and easily adaptable to changes to the underlying service without having to change our own code implementation. The example I have gone through here might seem convoluted, but I once showed all of this to a junior programmer who had been struggling to understand dependency injection. As soon as they saw the effect of not using dependency injection and all of the complexity involved they said that they understood why it was used. I wanted to include this example here as it really shows how services mask complexity.

Creating Custom Services With Injected Services

Whilst it is possible to use the \Drupal::service() construct wherever you need it, this shouldn't be used most of the time. Actually, it technically should only be used in static methods, hooks and theme functions where the flat function structure doesn't lend itself to dependency injection. Drupal allows you to inject it into the services into classes so that they are there and ready to use. When you are developing your own modules you will probably want to create your own services, which might have their own services being injected into them. The best way to show this in action is with a simple example.

To create a service we need to create a services.yml file. In the following example we are defining a custom service called mymodule.service_example that creates an object called ServiceExample, which will be created with another service called config.factory. The config.factory service is used to access the configuration of the Drupal site and is quite a common service to use.

services:
  mymodule.service_example:
    class: Drupal\mymodule\ServiceExample
    arguments: ['@config.factory']

Next, we need to create the ServiceExample class. It's best practice to create an interface that comes with your service, in the example this is ServiceExampleInterface. Using an interface allows you to follow proper SOLID principles by allowing other services that use this service to also accept different types of this class, which allows for better unit tests and a more versatile codebase.

The ServiceExample class just needs a constructor to accept the config.factory service and a class parameter to keep it in. When the object is created the config factory will automatically be injected into it, ready to use. I have added an example method called doThing() that makes use of the service.

<?php

namespace Drupal\mymodule;

use Drupal\Core\Config\ConfigFactoryInterface;

class ServiceExample implements ServiceExampleInterface {

  /**
   * The config name.
   *
   * @var string
   */
  protected $configName = 'mymodule.settings';

  /**
   * The config factory object.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * Constructs a ServiceExample object.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   A configuration factory instance.
   */
  public function __construct(ConfigFactoryInterface $config_factory) {
    $this->configFactory = $config_factory;
  }

  public function doThing() {
    $config = $this->configFactory->get($this->configName);
  }
}

To make use of this service you just need to create and use it like any other service. As an example we could use a hook_preprocess_block() to alter things within the block rendering system and use our new service to perform those modifications.

function mymodule_preprocess_block(&$variables) {
  \Drupal::service('mymodule.service_example')->doThing($variables);
}

This construct is pretty much the same for any service you want to inject. For example, you want to use the path_alias.manager service you just need to add the service to the modules services.yml file and then update the class to accept that new parameter. Once the service is created you can use the object just like normal.

Drupal Dependency Injection Interface

Controllers and Forms in Drupal are not defined through the services file and as a result they need a different mechanism to allow dependency injection to be used. In the case of Controllers and Forms the dependency injection is built right into the class and so it has a slightly different construct.

Controllers extend ControllerBase and Forms extend FormBase, both of which implement ContainerInjectionInterface. This interface needs to implement a static method called create(). This method is used to create the services that are needed by the class, which are then automatically injected into the object when it is created.

As an example, the following controller class called ExampleController injects the config.factory service using the create() dependency injection interface.

class ExampleController extends ControllerBase {

  /**
   * The config factory object.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('config.factory')
    );
  }

  /**
   * Constructs a ExampleController object.
   *
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   A configuration factory instance.
   */
  public function __construct(ConfigFactoryInterface) {
    $this->configFactory = $configFactory;
  }
}

This controller is used in the normal way, we just need to define a route that uses this class in the module's routing.yml file. This calls a method in the class called page() that has access to all of the services that we have injected into the class using the create() construct.

mymodule_example_controller:
  path: '/example'
  defaults:
    _controller: '\Drupal\mymodule\Controller\ExampleController::page'
    _title: 'Example'
  requirements:
    _access: 'TRUE'

That's pretty much it, just remember that if the class you are extending implements ContainerInjectionInterface then it uses the create() method to do the dependency injection for the class. If not, then you need to define the services in your module services.yml file.

Conclusion

As I said at the start of this article, this can be a bit of a complex topic, especially for beginners to Drupal. Once you get your head around it, it becomes a really powerful tool and allows you to pull in different services without having to write lots of complex and fragile code. You can even override services and inject your own classes using ServiceProviderBase that I have talked about previously to poke holes in the Shield module. Just remember that services and their dependencies are defined in *.services.yml files and Controllers and Forms can have dependency injection built into them on creation.

Services and their automated dependency injection is intended to make your life as a developer easier so that you can concentrate on the code that matters to you.

Bạn thấy bài viết này như thế nào?
0 reactions

Add new comment

Image CAPTCHA
Enter the characters shown in the image.
Câu nói tâm đắc: “Điều tuyệt với nhất trong cuộc sống là làm được những việc mà người khác tin là không thể!”

Related Articles

Master list (in progress) of how to get parts of fields for use in Twig templates. I’m always having to look these up, so I thought I’d hash them out and write them down.

Litespeed Cache là plugin WordPress dùng để kết hợp với Web Server LiteSpeed nhằm tăng tốc website WordPress của bạn gấp nhiều lần

In this article, we are going to see how some tools & libraries will make people's lives easier during the development & code review process.

In this tutorial, you will learn how to improve the custom code, theme and module, and general code development by using the pre-commit hook on git

Trước khi tìm hiểu xem PHP Code Sniffer là gì thì các bạn cần phải nắm được coding convention là gì đã.