Symfony 5: applicazioni multiple in un unico progetto

Symfony 5: applicazioni multiple in un unico progetto

symfonyingegneria

Immagina di dover realizzare una applicazione web che sia composta da 3 diversi servizi: un API, un pannello di amministrazione ed infine una parte pubblicamente accessibile.

In una situazione del genere potresti valutare l’idea di creare tre distinte applicazioni Symfony su altrettanti repository separati. Tuttavia, per un progetto di piccole dimensioni, lo sforzo di mantenere sincronizzati tre repository distinti potrebbe essere eccessivo e farti perdere una montagna di tempo per duplicare del codice che potrebbe essere scritto una sola volta.

Per migliorare la situazione e procedere più velocemente potresti quindi valutare l’idea di creare una distinzione tra i vari servizi definendo dei prefissi specifici per le rotte (ad esempio: example.it/admin, example.it/api e example.it/site). Tuttavia anche questo approccio non è facilmente scalabile. Infatti, nel momento in cui Symfony deve processare una richiesta questa viene fatta passare per il file public/index.php che si occupa di istanziare il kernel (src/Kernel.php) che a sua volta si occuperà della sua elaborazione e della creazione di una risposta. Poiché utilizzando questo tipo di soluzione la distinzione tra le applicazioni è solamente logica si avrebbe un singolo Kernel incaricato di risolvere tutte le richieste che arrivano. Questo si traduce in un potenziale spreco di risorse visto che per una richiesta che arriva dall’area pubblicamente accessibile dell’applicazione potrei non aver bisogno di caricare anche il servizio che si occupa di serializzazione degli oggetti per l’API. Di conseguenza, avendo un unico Kernel condiviso tra tutti i servizi che compongono la mia applicazione, potrei non avere un modo facile e veloce per differenziare i servizi caricati ad ogni richiesta.

Per ottimizzare le risorse, quello che si poteva fare con le versioni precedenti di Symfony 3 era definire più Kernel separati in modo che ogni applicazione fosse responsabile dei propri servizi. Sfortunatamente da Symfony 4 questa procedura è stata deprecata senza che però la documentazione indicasse una valida alternativa.

Cercando sul web, tra le risposte di stackoverflow, ho trovato una soluzione molto elegante che sfrutta il concetto di "name-based virtual Kernel". L’idea alla base di questo approccio è quello di utilizzare comunque un unico Kernel ma che sia in grado di caricare dinamicamente la configurazione da usare a seconda delle variabili d’ambiente passate. In questo modo si hanno tutti i vantaggi derivanti dall’utilizzo di un singolo Kernel ma di fatto si rendono i vari servizi che costituiscono l’applicazione completamente indipendenti.

Ma prima di entrare nel vivo dell’argomento lasciami presentare: sono Lorenzo Millucci e sono un ingegnere del software che ama lavorare con Symfony e a cui piace condividere in questo blog le cose che impara. Non perderti anche il mio canale Telegram in cui ogni martedì pubblico la curiosità tecnologica che più mi ha colpito nella settimana!

Ma bando alla ciance e vediamo subito come fare a realizzare un’applicazione Symfony con Kernel virtuale.

Strutturare i file di configurazione #

Per lo scopo di questo progetto assumerò che tu parta da un’applicazione Symfony nuova e che il progetto consista in 3 diverse applicazioni: api, admin e site.

La prima cosa da fare è strutturare i file di configurazione della cartella /config in modo che ogni applicazione abbia i suoi file dedicati facendo in questo modo:

├── config/
│    ├── admin/
│    │     ├── packages/
│    │     ├── routes
│    │     │    └─annotations.yaml
│    │     ├── bundles.php
│    │     └── services.yaml
│    ├── api/
│    │     ├── packages/
│    │     ├── routes
│    │     │    └─annotations.yaml
│    │     ├── bundles.php
│    │     └── services.yaml
│    ├── site/
│    │     ├── packages/
│    │     ├── routes
│    │     │    └─annotations.yaml
│    │     ├── bundles.php
│    │     └── services.yaml
│    ├── packages/
│    ├── bootstrap.php
│    └── bundles.php

NOTA: i file config/packages/*, config/bootstrap.php e config/bundles.php sono quelli di generati dall'installazione di Symfony e non sono stati modificati.

Siccome i passi da seguire sono uguali per tutte e tre le applicazioni che compongono il progetto in questo articolo ti mostrerò solamente come ho impostato i files di configurazione per admin. Affinché il progetto funzioni completamente sappi che dovrai ripetere gli stessi passi anche per i file e le configurazioni delle altre applicazioni (al netto di eventuali cambiamenti dei path).

File: config/admin/bundles.php #

Questo è il file che ti permette di definire dei bundle aggiuntivi specifici per il Kernel admin (i bundle comuni a tutte le app sono definiti nel file config/bundles.php).

Siccome in questo momento non vogliamo aggiungere bundle all’applicazione, il contenuto del file sarà molto semplicemente:

<?php
  // config/admin/bundles.php

  return [];

File: config/admin/services.yaml #

Questo file è il cuore della configurazione dell’applicazione. È proprio all’interno di questo file che vengono definiti quali sono i services e i controller da caricare.

Al fine di far funzionare correttamente il pannello di amministrazione andrà specificato in questo file che il Kernel di Symfony dovrà caricare tutti i files contenuti nella cartella src/Admin modificandolo nel seguente modo:

# config/admin/services.yaml

parameters:
  services:
      _defaults:
          autowire: true
          autoconfigure: true

      # Rendiamo i file all'interno della cartella src/Admin importabili come servizi
      Admin\:
          resource: '%kernel.project_dir%/src/Admin/*'
          exclude: '../src/{DependencyInjection,Entity,Migrations,Tests,Kernel.php}'

      # Definizione dei controller
      Admin\Controller\:
          resource: '%kernel.project_dir%/src/Admin/Controller'
          tags: ['controller.service_arguments']

File: config/admin/routes/annotations.yaml #

Siccome per la definizione di rotte viene utilizzato di default il sistema delle Annotations dobbiamo configurare l’applicazione in modo che vengano caricate automaticamente quelle presenti nella cartella src/Admin/Controller/. Per fare ciò dovrai creare il file config/admin/routes/annotations.yaml nel seguente modo:

# config/admin/routes/annotations.yaml
  controllers:
      resource: ../../../src/Admin/Controller/
      type: annotation

  kernel:
      resource: ../../../src/VirtualKernel.php
      type: annotation

Strutturare la cartella src #

Una volta che i file di configurazione sono stati preparati sei pronto per iniziare a strutturare la cartella che andrà a contenere il codice sorgente del progetto. Anche in questo caso la cosa migliore da fare è separare in cartelle differenti i file di ogni applicazione nel seguente modo:

├── src/
│    ├── Admin/
│    │    └── Controller/
│    ├── Api/
│    │    └── Controller/
│    ├── Site/
│    │    └── Controller/
│    └── VirtualKernel.php

NOTA: L’operazione più importante in questa fase è la trasformazione del file Kernel.php in VirtualKernel.php. Grazie a questa modifica sarà possibile caricare dinamicamente i file di configurazione corretti a seconda dell’applicazione da servire.

File: src/VirtualKernel.php #

La prima cosa da fare è modificare il costruttore del file VirtualKernel.php in modo tale che accetti come parametro anche il nome dell’applicazione da inizializzare:

// src/VirtualKernel.php
class VirtualKernel extends BaseKernel
{
    use MicroKernelTrait;

    private const CONFIG_EXTS = '.{php,xml,yaml,yml}';

    protected ?string $applicationName;

    public function __construct(string $environment, bool $debug, ?string $applicationName)
    {
        $this->applicationName = $applicationName;
        parent::__construct($environment, $debug);
    }
}

Subito dopo devi aggiungere un metodo di utilità che ritorni il path corretto dei file di configurazione a seconda dell’applicazione avviata:

class VirtualKernel extends BaseKernel
{
  //...
  private function getApplicationSpecificConfigPath(): string
  {
      return "{$this->getProjectDir()}/config/{$this->applicationName}";
  }
}

Una volta fatto ciò devi fare in modo che all’avvio del kernel, se è stata specificata un’applicazione specifica, vengano caricati sia i file di configurazioni comuni a tutte le app che quelli dedicati all’applicazione. In particolare è necessario modificare i metodi registerBundles(), configureContainer() e configureRoutes().

Forse ti starai chiedendo come mail l’attributo applicationName sia nullable. La risposta a questa domanda è che potrebbe tornarti utile utilizzare il kernel senza specificare un’applicazione specifica (ad esempio per creare le migrazioni tramite il comando doctrine:migrations:diff).

class VirtualKernel extends BaseKernel
{

  //...

  public function registerBundles(): iterable
  {
      $commonBundles = require $this->getProjectDir().'/config/bundles.php';
      // Register application specific bundles if needed
      $kernelBundles = $this->applicationName
        ? require $this->getApplicationSpecificConfigPath().'/bundles.php'
        : [];
      foreach (array_merge($commonBundles, $kernelBundles) as $class => $envs) {
        if ($envs[$this->getEnvironment()] ?? $envs['all'] ?? false) {
            yield new $class();
        }
      }
  }

  protected function configureContainer(ContainerBuilder $container, LoaderInterface $loader): void
  {
      $container->addResource(new FileResource($this->getProjectDir().'/config/bundles.php'));
      if ($this->applicationName) {
        $container->addResource(new FileResource("{$this->getApplicationSpecificConfigPath()}/bundles.php"));
      }
      $container->setParameter('container.dumper.inline_class_loader', \PHP_VERSION_ID < 70400 || $this->debug);
      $container->setParameter('container.dumper.inline_factories', true);
      // First configure common package and services
      $this->doConfigurePackageAndServices($loader);
      // Then configure application specific package and services
      if ($this->applicationName) {
        $this->doConfigurePackageAndServices($loader, $this->applicationName);
      }
  }

  protected function configureRoutes(RoutingConfigurator $routes): void
  {
      $this->doConfigureRoutes($routes);
      if ($this->applicationName) {
        $this->doConfigureRoutes($routes, $this->applicationName);
      }
  }

  private function doConfigurePackageAndServices(
      LoaderInterface $loader,
      string $applicationName = null
  ): void {
      $confDir = $applicationName ? $this->getApplicationSpecificConfigPath() : "{$this->getProjectDir()}/config";
      $loader->load($confDir.'/{packages}/*'.self::CONFIG_EXTS, 'glob');
      $loader->load($confDir.'/{packages}/'.$this->getEnvironment().'/*'.self::CONFIG_EXTS, 'glob');
      $loader->load($confDir.'/{services}'.self::CONFIG_EXTS, 'glob');
      $loader->load($confDir.'/{services}_'.$this->getEnvironment().self::CONFIG_EXTS, 'glob');
  }

  private function doConfigureRoutes(RoutingConfigurator $routes, string $applicationName = null): void
  {
      $confDir = $applicationName ? $this->getApplicationSpecificConfigPath() : "{$this->getProjectDir()}/config";
      $routes->import($confDir.'/{routes}/'.$this->getEnvironment().'/*'.self::CONFIG_EXTS);
      $routes->import($confDir.'/{routes}/*'.self::CONFIG_EXTS);
      $routes->import($confDir.'/{routes}'.self::CONFIG_EXTS);
  }
}

Modificare composer.json #

Per fare in modo che il nuovo kernel venga caricato è necessario modificare il file composer.json nel seguente modo:

{
    "autoload": {
        "classmap": [
            "src/VirtualKernel.php"
        ],
        "psr-4": {
            "Admin\\": "src/Admin/",
            "Api\\": "src/Api/",
            "Site\\": "src/Site/",
        }
    }
}

NOTA: Ricordati di ricaricare le modifiche con il comando da terminale composer dump-autoload.

Modificare index.php #

Arrivati a questo punto il gioco è fatto e non rimane altro che fare in modo che il nuovo Kernel venga caricato quando una richiesta viene processata dal file public/index.php. Per farlo ti basta modificare la riga in cui viene avviato il kernel con questa:

$kernel = new VirtualKernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG'], getenv('APP_NAME'));

Utilizzare l’applicazione #

A questo punto a seconda del server che utilizzi per gestire l’applicazione PHP dovrai modificare la configurazione per fare in modo che a seconda del path navigato dall’utente venga fornita al sistema la giusta variabile d’ambiente.

Ad esempio se utilizzi il server PHP dovrai lanciarlo nel seguente modo:

APP_NAME=site php -S 127.0.0.1:8000 -t public
APP_NAME=admin php -S 127.0.0.1:8001 -t public
APP_NAME=api php -S 127.0.0.1:8002 -t public

Eseguire i comandi da console #

Per poter lanciare i comandi da console relativi ad una specifica applicazione è necessario modificare lo script bin/console.php in modo che sia possibile specificare quale kernel avviare tramite un opzione --kernel (o -k).

// bin/console.php

// ...

$appName = $input->getParameterOption(['--kernel', '-k'], getenv('APP_NAME') ?: null);
$kernel = new VirtualKernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG'], $appName);
$application = new Application($kernel);
$application->getDefinition()
    ->addOption(new InputOption('--kernel', '-k', InputOption::VALUE_OPTIONAL, 'The Kernel name'));
$application->run($input);

In questo modo sarà quindi possibile lanciare i comandi da console specificando l’applicazione da eseguire indicandola tramite il parametro --kernel:

php bin/console about --kernel api

Repository GitHub #

Tutto il codice di questo progetto lo puoi trovare in questo repository.


Se questo post ti è piaciuto e ti è stato utile ti invito ad iscriverti al mio canale Telegram. Se invece hai domande o vuoi lasciare un commento puoi contattarmi direttamente su Telegram o su Twitter. A presto!

Fonte