Services

Un servizio è un oggetto PHP che svolge una responsabilità specifica e riutilizzabile. Il core di Drupal mette a disposizione servizi per operazioni come:

  • accedere alla configurazione (ConfigFactoryInterface)
  • caricare e salvare entità
  • eseguire query sul database
  • inviare richieste HTTP (ClientInterface)
  • scrivere messaggi nel log (LoggerInterface)
  • gestire la cache (CacheBackendInterface)
  • inviare email
  • recuperare l'utente corrente
  • tradurre stringhe

I servizi forniti dal core sono definiti nel file core/core.services.yml, se è installato il modulo Devel è possibile utilizzare il comando drush devel:services che restituisce l'elenco dei servizi presenti nel container del sito corrente

Anche un modulo custom può definire un proprio servizio e anche renderlo disponibile ad altri componenti.

Tutti i servizi vengono raccolti nel Dependency Injection Container (o Service Container) che è responsabile di:

  • conservare le definizioni dei servizi
  • conoscere le dipendenze di ogni servizio
  • costruire gli oggetti necessari
  • passare le dipendenze ai loro costruttori
  • restituire i servizi ai componenti che li richiedono

Quando viene richiesto un servizio il container:

  1. recupera o costruisce ciascuna dipendenza
  2. crea un oggetto
  3. passa le dipendenze al costruttore
  4. restituisce l'oggetto al componente che lo ha richiesto

Dipendenze

Una classe ha una dipendenza quando ha bisogno di un altro oggetto per svolgere il proprio lavoro, ad esempio un sevizio che deve recuperare dati dal server non può effettuare autonomamente una richiesta HTTP senza utilizzare il client HTTP ClientInterface $httpClient.

Ha quindi una dipendenza da un oggetto che implementa ClientInterface:

public function __construct( 
  private readonly ClientInterface $httpClient, 
) {}

//esempio SCORRETTO
public function getForecast(): ?array { 
  $httpClient = new Client(); 
  // ... 
}

Interfaccia del servizio

L'interfaccia definisce il contratto del servizio, cioè:

  • quali metodi sono disponibili
  • quali parametri ricevono
  • quali dati restituiscono

L'interfaccia sarà contenuta nel file src/Service/ForecastClientInterface.php:

<?php 

declare(strict_types=1);

namespace Drupal\nome_modulo\Service; 

/** 
 * Provides access to weather forecast data. 
*/
interface ForecastClientInterface { 
    
/** 
 * Returns the current weather forecast. 
 * 
 * @return array{ 
 * location: string, 
 * timezone: string, 
 * temperature_unit: string, 
 * days: list<array{ 
 *  date: string, 
 *  weather_code: int, 
 *  high: float, 
 *  low: float 
 *  }> 
 * }|null 
 * The normalized forecast data, or NULL when it cannot be retrieved. 
 */
    public function getForecast(): ?array;
}

Il controller dipenderà quindi da ForecastClientInterface e non direttamente da ForecastClient.

Il file .services.yml

services: 
  cache.nome_modulo: # cache bin dedicato al modulo
    class: Drupal\Core\Cache\CacheBackendInterface 
    tags:
      - { name: cache.bin } # etichetta il servizio come cache bin
    factory: ['@cache_factory', 'get'] # indica che che il container non deve costruire direttamente un oggetto attraverso new
    arguments: [nome_modulo] 

 logger.channel.nome_modulo: 
    parent: logger.channel_base 
    arguments:
      - nome_modulo

  nome_modulo.forecast_client: # service ID
    class: Drupal\nome_modulo\Service\ForecastClient # Fully Qualified Class Name della classe che il container deve costruire
    arguments:
      - '@http_client' # la @ indica che si tratta di un sevizio
      - '@config.factory' 
      - '@cache.nome_modulo' 
      - '@logger.channel.nome_modulo' 
      - '@datetime.time' 

  nome_modulo.forecast_status_storage: 
    class: Drupal\nome_modulo\ForecastStatusStorage 
    arguments: 
      - '@state'

Drupal\nome_modulo\Service\ForecastClientInterface: '@nome_modulo.forecast_client' # crea un'alias

Implementazione del servizio

<?php 

declare(strict_types=1);

namespace Drupal\nome_modulo\Service;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use Psr\Log\LoggerInterface;

/** 
 * Retrieves and normalizes weather forecast data. 
*/
final class ForecastClient implements ForecastClientInterface { 
    
/** 
 * Weather provider endpoint. 
 */
    private const API_ENDPOINT = 'https://api.open-meteo.com/v1/forecast'; 
    
    /** 
    * Cache lifetime in seconds. 
    */
    private const CACHE_MAX_AGE = 1800; 
    
    /** 
     * Cache tag used for forecast data. 
     */
    private const CACHE_TAG = 'nome_modulo:forecast'; 
    
    /** 
     * Constructs a ForecastClient object. 
     */
    public function __construct(
        private readonly ClientInterface $httpClient, // l’HTTP client utilizzato per comunicare con il provider esterno
        private readonly ConfigFactoryInterface $configFactory, // permette di recuperare nome_modulo.settings
        private readonly CacheBackendInterface $cache, // Senza cache, ogni visualizzazione della pagina produrrebbe una nuova richiesta HTTP
        private readonly LoggerInterface $logger, //per scrivere i log
        private readonly TimeInterface $time, // Permette di ottenere il timestamp corrente
        )
    { } 
    
    /** 
     * {@inheritdoc} 
     */
    public function getForecast(): ?array {
        $config = $this->configFactory->get('nome_modulo.settings'); // qui i dati li leggiamo dalla tabella config del db, non dal file nome_modulo.settings

        $latitude = $config->get('latitude'); // in nome_modulo.settings avremo la voce latitude
        $longitude = $config->get('longitude'); // in nome_modulo.settings avremo la voce longitude

        if (!is_numeric($latitude) || !is_numeric($longitude)) {
            $this->logger->error('The weather forecast cannot be retrieved because the coordinates are missing or invalid.', );
            return NULL;
        }

        $location = (string) ($config->get('location') ?? '');
        $timezone = (string) ($config->get('timezone') ?? 'UTC');

        $forecastDays = (int) ($config->get('forecast_days') ?? 5);
        $forecastDays = max(1, min(16, $forecastDays));

        $temperatureUnit = (string) ($config->get('temperature_unit') ?? 'celsius');

        if (!in_array($temperatureUnit, ['celsius', 'fahrenheit'], TRUE)) {
            $temperatureUnit = 'celsius';
        }

        $cacheId = $this->buildCacheId(
            (float) $latitude, 
            (float) $longitude, 
            $timezone, 
            $forecastDays, 
            $temperatureUnit, );

        $cached = $this->cache->get($cacheId);

        if ($cached !== FALSE && is_array($cached->data)) {
            return $cached->data;
        }

        try {
            $response = $this->httpClient->request('GET', self::API_ENDPOINT, [
                'query' => [
                    'latitude' => (float) $latitude, 
                    'longitude' => (float) $longitude, 
                    'daily' => implode(',', [
                        'weather_code', 
                        'temperature_2m_max', 
                        'temperature_2m_min',
                        ]), 
                        'timezone' => $timezone, 
                        'forecast_days' => $forecastDays, 
                        'temperature_unit' => $temperatureUnit,
                    ], 
                    'headers' => [
                        'Accept' => 'application/json',
                    ], 
                    'timeout' => 10,
                ]);

            $data = json_decode(
                (string) $response->getBody(), 
                TRUE, 
                512, 
                JSON_THROW_ON_ERROR, 
            );

            if (!is_array($data)) {
                throw new \UnexpectedValueException('The weather provider returned an invalid response.', );
            }

            $forecast = $this->normalizeForecast($data);

            $forecast['location'] = $location;

            $this->cache->set(
                $cacheId, 
                $forecast, 
                $this->time->getCurrentTime() + self::CACHE_MAX_AGE, 
                [self::CACHE_TAG], 
            );

            return $forecast;
        } catch (GuzzleException | \JsonException | \UnexpectedValueException $exception) {
             // GuzzleException è un errore HTTP, rete, timeout o risposta non valida
             // JsonException quando il corpo della risposta non è decodificabile come JSON
             // UnexpectedValueException  quando il JSON è valido ma la struttura non è conforme a quella attesa
            $this->logger->error(
                'The weather forecast could not be retrieved: @message', 
                ['@message' => $exception->getMessage()], 
            );

            return NULL;
        }
    } 
    
    /** 
     * Builds a cache ID for the requested forecast. 
     */
    private function buildCacheId(
        float $latitude, 
        float $longitude, 
        string $timezone, 
        int $forecastDays, 
        string $temperatureUnit, 
    ): string
    {
        $parameters = implode(':', [
            $latitude, 
            $longitude, 
            $timezone, 
            $forecastDays, 
            $temperatureUnit,
        ]);
        return 'nome_modulo:forecast:' . hash('sha256', $parameters);
    } 
    
    /** 
     * Converts the provider response into the module's internal format. 
     * 
     * @param array<string, mixed> $data 
     * The decoded provider response. 
     * 
     * @return array{ 
     *  timezone: string, 
     *  temperature_unit: string, 
     *  days: list<array{ 
     *      date: string, 
     *      weather_code: int, 
     *      high: float, 
     *      low: float 
     *  }> 
     * } 
     * 
     * The normalized forecast. 
     */
    private function normalizeForecast(array $data): array {
        $daily = $data['daily'] ?? NULL;
        $dailyUnits = $data['daily_units'] ?? NULL;

        if (!is_array($daily) || !is_array($dailyUnits)) {
            throw new \UnexpectedValueException('The response does not contain daily forecast data.', );
        }

        $dates = $daily['time'] ?? NULL;
        $weatherCodes = $daily['weather_code'] ?? NULL;
        $highs = $daily['temperature_2m_max'] ?? NULL;
        $lows = $daily['temperature_2m_min'] ?? NULL;

        if (
            !is_array($dates) || 
            !is_array($weatherCodes) || 
            !is_array($highs) || 
            !is_array($lows)
        ) {
            throw new \UnexpectedValueException('The daily forecast has an invalid structure.', );
        }

        $numberOfDays = count($dates);

        if (
            count($weatherCodes) !== $numberOfDays || 
            count($highs) !== $numberOfDays || 
            count($lows) !== $numberOfDays
        ) {
            throw new \UnexpectedValueException('The daily forecast contains inconsistent data.', );
        }

        $days = [];

        foreach ($dates as $index => $date) {
            if (
                !is_string($date) || 
                !is_numeric($weatherCodes[$index]) || 
                !is_numeric($highs[$index]) || 
                !is_numeric($lows[$index])
            ) {
                throw new \UnexpectedValueException('The daily forecast contains an invalid value.', );
            }

            $days[] = [
                'date' => $date, 
                'weather_code' => (int) $weatherCodes[$index], 
                'high' => (float) $highs[$index], 
                'low' => (float) $lows[$index],
            ];
        }

        return [
            'timezone' => (string) ($data['timezone'] ?? 'UTC'), 
            'temperature_unit' => (string) (
                $dailyUnits['temperature_2m_max'] ?? ''
            ), 
            'days' => $days,
        ];
    }
}

Dependency Injection

Il costruttore riceve il servizio attraverso la sua interfaccia, non utilizza new ForecastClient(...), :

public function __construct( 
    private readonly ForecastClientInterface $forecastClient, 
) {}

Il metodo statico create() recupera il servizio dal container, riceve il Service Container, richiede l'oggetto associato all'interfaccia, passa l'oggetto al costruttore e restituisce il controller inizializzato:

public static function create(ContainerInterface $container): static { 
  return new static( 
    $container->get(ForecastClientInterface::class), // restituisce Drupal\nome_modulo\Service\ForecastClientInterface
  ); 
}