Riepilogo post nella categoria Laravel Performance

Nello sviluppo di applicazioni web con Laravel, ci imbattiamo spesso in situazioni in cui un determinato valore, risultato di un calcolo costoso o del recupero di dati, è necessario più volte durante il ciclo di vita di una singola richiesta HTTP o l'esecuzione di un singolo comando Artisan. Ripetere queste operazioni onerose inutilmente può portare a un degrado delle performance e a uno spreco di risorse. Per ovviare a ciò, si ricorre alla memoizzazione: calcolare il valore una volta e conservarlo in memoria per riutilizzarlo nelle chiamate successive all'interno dello stesso ciclo.

Nelle applicazioni Laravel 9 o Laravel 10 più datate, o in quelle dove non si è ancora adottata una soluzione framework-native più recente, la memoizzazione per-richiesta veniva spesso implementata con soluzioni custom: proprietà di istanza in servizi singleton, proprietà statiche, o talvolta un uso un po' forzato del cache store di Laravel. Con l'introduzione dell'helper once() in Laravel 11 (e quindi parte integrante di un moderno stack Laravel 12), il framework offre ora un modo estremamente elegante, conciso e sicuro per gestire questo pattern.

Questo articolo tecnico ti guiderà nel refactoring di queste implementazioni di memoizzazione custom, mostrando come l'helper once() possa semplificare il tuo codice, renderlo più leggibile e robusto, a tutto vantaggio delle applicazioni della tua impresa.

Se vuoi approfondire, continua a leggere. Se hai una domanda specifica a riguardo di questo articolo, contattami per una consulenza dedicata. Dai anche un'occhiata al mio profilo per capire come posso aiutare concretamente la tua azienda o startup a crescere e a modernizzarsi.

Memoizzazione "custom" in Laravel 9/10: tecniche comuni e i loro limiti

Vediamo alcuni approcci che potresti trovare (o aver implementato) in un'applicazione Laravel 9/10 per ottenere la memoizzazione per-richiesta.

Approccio 1: Proprietà d'istanza in un servizio

Una tecnica comune è utilizzare una proprietà all'interno di un servizio (spesso registrato come singleton nel service container) per "ricordare" un valore calcolato.

// Esempio Servizio L9/L10 con memoizzazione in proprietà d'istanza
namespace App\Services\Legacy;

use App\Models\User; // Assumiamo un model User
use Illuminate\Support\Facades\DB; // Per un esempio di query

class CompanyReportService
{
    protected ?User $currentUser = null;
    protected ?array $cachedUserReportData = null;

    // Il servizio potrebbe essere un singleton o istanziato per richiesta
    public function __construct(User $currentUser = null)
    {
        $this->currentUser = $currentUser ?? auth()->user();
    }

    /**
     * Recupera dati di reportistica costosi per l'utente.
     */
    public function getUserReportData(): array
    {
        if (is_null($this->currentUser)) {
            return ['error' => 'Utente non specificato'];
        }

        if (is_null($this->cachedUserReportData)) {
            logger()->info("CompanyReportService: Calcolo dati report per utente ID {$this->currentUser->id}");
            // Simula un calcolo costoso (es. aggregazione da più tabelle)
            sleep(1); // Simula latenza
            $this->cachedUserReportData = DB::table('sales')
                                        ->where('user_id', $this->currentUser->id)
                                        ->join('products', 'sales.product_id', '=', 'products.id')
                                        ->selectRaw('products.category, SUM(sales.amount) as total_sales')
                                        ->groupBy('products.category')
                                        ->orderByDesc('total_sales')
                                        ->get()
                                        ->toArray();
        }
        return $this->cachedUserReportData;
    }
}

// Utilizzo nel controller:
// $reportService = app(CompanyReportService::class); // Risolto come singleton o nuova istanza
// $data1 = $reportService->getUserReportData(); // Calcola la prima volta
// $data2 = $reportService->getUserReportData(); // Usa il valore cachato $this->cachedUserReportData
  • Limiti:
    • Funziona bene se il servizio è un singleton (una sola istanza per richiesta). Se viene creata una nuova istanza del servizio più volte nella stessa richiesta, la memoizzazione non ha effetto tra le istanze.
    • La logica di "check-if-null-then-calculate" è boilerplate ripetuto.
    • La proprietà cachedUserReportData è specifica per questo calcolo. Se il servizio ha più metodi che necessitano di memoizzazione, servono più proprietà di cache.

Approccio 2: Proprietà statiche in una classe

Per valori che non dipendono dallo stato di un'istanza specifica ma devono essere calcolati una sola volta per richiesta a livello globale.

// Esempio con proprietà statica (L9/L10)
namespace App\Services\Legacy;

use Illuminate\Support\Facades\Http;

class ExternalApiServiceConfig
{
    protected static ?array $serviceEndpoints = null;
    protected static int $callCount = 0; // Solo per debug

    public static function getEndpoints(): array
    {
        if (is_null(self::$serviceEndpoints)) {
            self::$callCount++;
            logger()->info("ExternalApiServiceConfig: Caricamento endpoint da servizio esterno (chiamata #" . self::$callCount . ")");
            // Simula una chiamata HTTP costosa per ottenere la configurazione degli endpoint
            sleep(1);
            // $response = Http::get('https://config.thirdparty.com/endpoints');
            // self::$serviceEndpoints = $response->json();
            self::$serviceEndpoints = [
                'users' => 'https://api.thirdparty.com/users',
                'products' => 'https://api.thirdparty.com/products',
            ];
        }
        return self::$serviceEndpoints;
    }

    // Necessario per il testing e per ambienti long-running come Octane
    public static function flushStaticCache(): void
    {
        self::$serviceEndpoints = null;
        self::$callCount = 0; // Resetta il contatore
    }
}

// Utilizzo:
// $endpoints1 = ExternalApiServiceConfig::getEndpoints(); // Chiama API e cacha staticamente
// $endpoints2 = ExternalApiServiceConfig::getEndpoints(); // Usa valore cachato staticamente
  • Limiti:
    • Stato globale: le proprietà statiche mantengono il loro stato per tutta la durata del processo PHP. In ambienti serverless o tradizionali PHP-FPM, questo di solito equivale a una singola richiesta. Ma in ambienti long-running come Laravel Octane, lo stato statico persiste tra le richieste se non esplicitamente resettato (da qui la necessità di un metodo come flushStaticCache). Questo può portare a data leak tra richieste o comportamenti inattesi.
    • Testabilità: mockare o resettare lo stato statico nei test può essere più complicato.

Approccio 3: Uso del Cache Store di Laravel (in modo improprio per memoizzazione per-richiesta)

Qualcuno potrebbe pensare di usare il cache store di Laravel (es. Redis, Memcached, file) con una durata molto breve (es. 1 secondo) per simulare una cache per-richiesta.

// Esempio di "abuso" del Cache Store (L9/L10)
use Illuminate\Support\Facades\Cache;
use App\Models\User;

// ...
public function getUserPermissions(User $user): array
{
    $cacheKey = "user_permissions_{$user->id}_" . request()->fingerprint(); // Chiave unica per utente e richiesta

    return Cache::remember($cacheKey, 1, function () use ($user) { // TTL di 1 secondo
        logger()->info("Cache Store: Calcolo permessi per utente {$user->id}");
        sleep(1); // Simula calcolo costoso
        return $user->load('roles.permissions')->roles->flatMap->permissions->pluck('name')->unique()->toArray();
    });
}
  • Limiti:
    • Overhead inutile: implica una chiamata al driver di cache esterno (es. rete per Redis/Memcached, I/O disco per file), serializzazione/deserializzazione dei dati. Tutto questo per un valore che serve solo per la durata della richiesta corrente. È molto inefficiente.
    • Complessità della chiave: generare una chiave di cache che sia unica per la richiesta e per i parametri specifici può essere complicato.
    • Rischio di collisioni o dati obsoleti: se il TTL non è gestito perfettamente o se la fingerprint() della richiesta non è sufficientemente univoca in tutti i contesti.

Laravel 11+ e l'helper once(): eleganza e semplicità per la memoizzazione per-richiesta

Laravel 11 ha introdotto l'helper globale once(callable $callback): mixed. Questa semplice funzione risolve elegantemente il problema della memoizzazione per-richiesta.

Come funziona: L'helper once() garantisce che la callback fornita venga eseguita una sola volta durante il ciclo di vita di una singola richiesta HTTP o l'esecuzione di un singolo comando Artisan. Il risultato della prima esecuzione viene cachato in memoria (associato all'oggetto o al contesto chiamante) e restituito a tutte le chiamate successive a once() con la stessa callback (nello stesso contesto) all'interno di quella richiesta/comando.

  • Thread-safe (in contesti come Octane con Swoole/RoadRunner): Laravel gestisce internamente la cache di once() in modo che sia sicura anche con le fibre PHP (se PHP >= 8.1 e l'estensione fibers è attiva) o con meccanismi di lock per versioni precedenti, assicurando che la callback sia effettivamente eseguita una sola volta anche con richieste concorrenti gestite dallo stesso processo worker.
  • Reset automatico tra le richieste: in ambienti long-running come Octane, la cache di once() viene automaticamente resettata tra una richiesta e l'altra, evitando i problemi delle proprietà statiche non gestite.
  • Legata al contesto: se usata all'interno di un metodo d'oggetto (non statico), la cache è specifica per quell'istanza dell'oggetto e per il metodo da cui once() è chiamato. Se usata in una closure statica o globalmente, la cache è legata alla closure stessa.

Guida pratica al refactoring con once()

Vediamo come refactorare gli esempi precedenti.

Refactoring dell'Approccio 1 (Proprietà d'istanza in un servizio):

// Esempio Servizio L11/L12 con helper once()
namespace App\Services\Modern;

use App\Models\User;
use Illuminate\Support\Facades\DB;

class CompanyReportService
{
    protected User $currentUser;

    public function __construct(User $currentUser = null)
    {
        $this->currentUser = $currentUser ?? auth()->user();
    }

    protected function loadUserReportDataOnce(): array
    {
        // La closure passata a once() viene eseguita solo la prima volta
        // che questo metodo (loadUserReportDataOnce) viene chiamato SU QUESTA ISTANZA ($this).
        // Laravel usa spl_object_id($this) e il nome del file/linea del chiamante
        // per creare una chiave univoca per la cache di once().
        return once(function () {
            if (is_null($this->currentUser)) {
                return ['error' => 'Utente non specificato per il report (once)'];
            }
            logger()->info("CompanyReportService (once): Calcolo dati report per utente ID {$this->currentUser->id}");
            sleep(1); // Simula latenza
            return DB::table('sales')
                        ->where('user_id', $this->currentUser->id)
                        ->join('products', 'sales.product_id', '=', 'products.id')
                        ->selectRaw('products.category, SUM(sales.amount) as total_sales')
                        ->groupBy('products.category')
                        ->orderByDesc('total_sales')
                        ->get()
                        ->toArray();
        });
    }

    public function getUserReportData(): array
    {
        return $this->loadUserReportDataOnce();
    }

    // Se avessi un altro metodo che necessita di un altro valore memoizzato:
    public function getAggregatedCompanyStats(): array
    {
        return once(function () {
            logger()->info("CompanyReportService (once): Calcolo statistiche aggregate azienda...");
            // ... altro calcolo costoso ...
            return ['total_revenue' => 100000, 'active_users' => 500];
        });
        // Nota: la cache per questo `once()` è separata da quella di `loadUserReportDataOnce()`
        // perché sono in contesti di chiamata diversi (metodi diversi o linee diverse).
    }
}

// Utilizzo:
// $reportService = app(CompanyReportService::class); // Immagina sia risolto con l'utente corrente
// $data1 = $reportService->getUserReportData(); // Calcola e cacha
// $data2 = $reportService->getUserReportData(); // Usa valore cachato
// $stats = $reportService->getAggregatedCompanyStats(); // Altro calcolo, cachato separatamente

Il codice è più pulito: la logica di "check-if-null" è sparita, delegata a once().

Refactoring dell'Approccio 2 (Proprietà statiche): L'helper once() è perfetto per sostituire l'uso di proprietà statiche per la memoizzazione per-richiesta, eliminando i rischi in ambienti long-running.

// Esempio con helper once() per sostituire proprietà statica (L11/L12)
namespace App\Services\Modern;

class ExternalApiServiceConfig
{
    public static function getEndpoints(): array
    {
        // La cache di once() qui è legata a questa specifica closure e al suo contesto statico.
        // Verrà resettata tra le richieste HTTP in ambienti come Octane.
        return once(function () {
            logger()->info("ExternalApiServiceConfig (once): Caricamento endpoint da servizio esterno...");
            sleep(1); // Simula chiamata HTTP costosa
            return [
                'users' => 'https://api.thirdparty.com/users',
                'products' => 'https://api.thirdparty.com/products',
                'version' => 'v2.1'
            ];
        });
    }

    public static function getEndpointFor(string $serviceKey): ?string
    {
        $endpoints = self::getEndpoints(); // Questa chiamata userà il valore memoizzato se già calcolato
        return $endpoints[$serviceKey] ?? null;
    }
}

// Utilizzo:
// $userEndpoint = ExternalApiServiceConfig::getEndpointFor('users'); // Carica e cacha endpoints
// $productEndpoint = ExternalApiServiceConfig::getEndpointFor('products'); // Usa endpoints cachati

Non c'è più bisogno di un metodo flushStaticCache().

Refactoring dell'Approccio 3 (Cache Store di Laravel): Questo è semplice: sostituisci la chiamata a Cache::remember() con once().

// Sostituzione dell'uso improprio di Cache::remember (L11/L12)
use App\Models\User;

// ...
public function getUserPermissions(User $user): array
{
    // La chiave di cache interna di once() sarà basata sull'oggetto $user (se $this è $user)
    // o sul contesto chiamante. Se questo metodo è in un servizio e $user è un parametro,
    // la cache di once() potrebbe non distinguere tra utenti diversi se la closure non li usa
    // per variare il suo "contesto" (es. se la closure è identica).
    // Per essere sicuri che sia per utente, la chiamata a once() deve avvenire in un contesto
    // che includa l'utente, o la closure stessa deve essere parametrizzata.
    // Un modo è chiamare once() su un metodo dell'oggetto User stesso, o in un servizio
    // che riceve l'utente e lo usa nella closure di once().

    // Esempio più corretto se questo metodo è in un servizio che non ha $user come proprietà:
    // return once(fn() => $this->calculateUserPermissions($user));
    // Oppure, se $user è una proprietà del servizio:
    return once(function () use ($user) { // `use ($user)` rende la closure unica per questo utente
        logger()->info("Helper once(): Calcolo permessi per utente {$user->id}");
        sleep(1);
        return $user->load('roles.permissions')->roles->flatMap->permissions->pluck('name')->unique()->toArray();
    });
    // Attenzione: se $user cambia ma la *definizione* della closure (stesso file/linea)
    // non cambia, e la closure non cattura $user nel suo scope in modo che `once` possa
    // distinguerla, potrebbe restituire il valore della prima chiamata.
    // L'implementazione di `once` (Once::class) usa un array statico
    // la cui chiave è generata da `debug_backtrace`. Se la chiamata avviene
    // sempre dalla stessa linea di codice, la cache potrebbe essere la stessa.
    // Per ovviare, si può usare un oggetto come primo argomento di `once`:
    // return once($user, fn() => $this->calculateUserPermissions($user));
    // Ma la documentazione suggerisce che `once` è legato all'oggetto chiamante,
    // o alla specifica istanza della closure (che qui cambia con `use ($user)`).
    // Laravel 11+ once helper (global) uses an instance of Illuminate\Support\Once per application instance.
    // The cache key is based on the call site (file + line + object hash if in method, or closure hash).
    // Closures with different `use` variables have different hashes.
}

Nota sulla Cache Key di once(): L'helper once() è abbastanza intelligente. Se lo chiami all'interno di un metodo di un oggetto, il valore cachato è associato a quell'istanza specifica dell'oggetto e al punto di chiamata. Se chiami once() con una closure che use variabili diverse, queste closure avranno hash diversi, portando a cache separate. Questo lo rende molto flessibile e sicuro per la maggior parte dei casi d'uso per-richiesta.

Uso di once() in altri contesti

  • Model Eloquent (Attributi Derivati Costosi):

      // app/Models/Order.php
      namespace App\Models;
      use Illuminate\Database\Eloquent\Model;
      class Order extends Model {
          // ...
          public function getTotalAmountWithTaxesAttribute(): float
          {
              return once(function () {
                  logger("Order {$this->id}: Calcolo importo totale con tasse...");
                  // Simula un calcolo costoso, magari con più chiamate a relazioni
                  sleep(1);
                  $subtotal = $this->items()->sum('price');
                  $taxRate = $this->region->tax_rate; // Altra chiamata a relazione
                  return $subtotal * (1 + $taxRate);
              });
          }
      }
  • Comandi Artisan: Per operazioni che potrebbero essere chiamate più volte durante una singola esecuzione del comando.

Testing del codice che usa once()

Il comportamento di once() si integra bene con il ciclo di vita dei test di Laravel. Il valore cachato da once() persiste per la durata di un singolo test. Per verificare che la callback di once() sia stata eseguita (o non eseguita, indicando che è stato usato un valore cachato), puoi:

  • Aggiungere un logging all'interno della callback e usare Log::spy().
  • Usare Mockery::spy() su un oggetto che viene chiamato solo all'interno della callback di once().
// tests/Unit/Services/ModernCompanyReportServiceTest.php
namespace Tests\Unit\Services\Modern;

use App\Services\Modern\CompanyReportService;
use App\Models\User;
use Illuminate\Support\Facades\DB; // Per il mocking delle query se necessario
use Illuminate\Support\Facades\Log;
use Illuminate\Foundation\Testing\RefreshDatabase; // Se il servizio interagisce con il DB
use Tests\TestCase;

class ModernCompanyReportServiceTest extends TestCase
{
    use RefreshDatabase;

    public function test_report_data_is_calculated_only_once_per_service_instance_per_request(): void
    {
        Log::spy(); // Spia il facade Log

        $user = User::factory()->create();
        $service = new CompanyReportService($user); // Crea istanza del servizio

        // Simula che la query DB ritorni qualcosa
        DB::shouldReceive('table->where->join->selectRaw->groupBy->orderByDesc->get->toArray')
            ->once() // Assicurati che la query DB sia fatta una sola volta
            ->andReturn([['category' => 'Test', 'total_sales' => 100]]);

        // Prima chiamata: la closure di once() dovrebbe essere eseguita
        $data1 = $service->getUserReportData();
        $this->assertNotEmpty($data1);

        // Seconda chiamata: la closure di once() NON dovrebbe essere eseguita di nuovo
        $data2 = $service->getUserReportData();
        $this->assertEquals($data1, $data2);

        // Terza chiamata con un altro metodo che usa once()
        $stats = $service->getAggregatedCompanyStats();
        $this->assertArrayHasKey('total_revenue', $stats);


        // Verifica che il logger specifico (dentro la closure di once() per getUserReportData)
        // sia stato chiamato esattamente una volta.
        Log::shouldHaveReceived('info')
            ->with(Mockery::pattern("/CompanyReportService \(once\): Calcolo dati report per utente ID {$user->id}/"))
            ->once();

        // E che il logger per getAggregatedCompanyStats sia stato chiamato una volta
         Log::shouldHaveReceived('info')
            ->with(Mockery::pattern("/CompanyReportService \(once\): Calcolo statistiche aggregate azienda\.\.\./"))
            ->once();
    }
}

Benefici del refactoring con once() per la tua impresa

Adottare l'helper once() per la memoizzazione per-richiesta porta a:

  • Codice Più Pulito e Leggibile: La logica di "esegui solo una volta" è incapsulata e dichiarativa.
  • Minore Rischio di Errori: Meno codice boilerplate custom significa meno superficie per bug nella logica di caching manuale.
  • Performance Migliorate: Previene in modo efficiente l'esecuzione ripetuta di calcoli o query costose all'interno di una singola richiesta.
  • Testabilità Semplificata: Il comportamento è predicibile e si integra bene con gli strumenti di testing di Laravel.
  • Gestione Corretta in Ambienti Long-Running (Octane): once() è progettato per funzionare correttamente in questi contesti, resettando automaticamente la sua cache tra le richieste, a differenza delle proprietà statiche non gestite.

Il ruolo del programmatore Laravel esperto

Identificare le opportunità per utilizzare once() e refactorare codice legacy che implementa memoizzazione custom in modo meno efficiente o sicuro è un compito in cui l'esperienza fa la differenza. Come sviluppatore laravel con un occhio attento all'ottimizzazione e alla pulizia del codice (vedi il mio approccio su Chi Sono), posso aiutare la tua impresa a:

  • Scansionare la base di codice alla ricerca di pattern di memoizzazione custom.
  • Implementare il refactoring verso once() in modo sicuro.
  • Assicurare che i test coprano il nuovo comportamento.

L'helper once() è un esempio di come Laravel continui a evolvere, offrendo strumenti semplici ma potenti per risolvere problemi comuni in modo elegante. È un piccolo cambiamento che può portare grandi benefici alla qualità e all'efficienza del codice delle applicazioni del tuo business.

Se vuoi ottimizzare la tua applicazione Laravel 9/10 e prepararla per un futuro con Laravel 12, sfruttando queste utili funzionalità per un codice più performante e manutenibile, contattami per una consulenza e un piano di modernizzazione.

Ultima modifica: Venerdì 28 Febbraio 2025, alle 13:05

Nelle applicazioni aziendali costruite con Laravel, capita frequentemente di dover eseguire operazioni batch che processano una grande quantità di dati o interagiscono con molteplici sistemi esterni. Che si tratti di inviare migliaia di notifiche email, sincronizzare dati da diverse fonti, generare report complessi o effettuare chiamate multiple a API di terze parti, l'approccio tradizionale di esecuzione sequenziale, comune in progetti Laravel 9 o Laravel 10 più datati, può rapidamente diventare un collo di bottiglia. Lunghi tempi di esecuzione per i comandi Artisan, richieste web che vanno in timeout e una generale scarsa reattività possono impattare negativamente sull'efficienza di un'impresa.

Fortunatamente, a partire da Laravel 11 (e quindi pienamente disponibile e consigliato per Laravel 12), il framework ha introdotto un potente componente per la gestione della concorrenza: Illuminate\Support\Facades\Concurrency. Questo strumento permette di eseguire più task contemporaneamente, sfruttando meglio le risorse del server e riducendo drasticamente i tempi di attesa, specialmente per operazioni I/O-bound.

In questo articolo tecnico, esploreremo come effettuare un refactoring di operazioni batch sequenziali, tipiche di un'applicazione Laravel 9/10, verso un approccio concorrente utilizzando Concurrency::run() e Concurrency::awaitall(), con esempi di codice dettagliati per illustrare i benefici in termini di performance per il tuo business.

Se vuoi approfondire, continua a leggere. Se hai una domanda specifica a riguardo di questo articolo, contattami per una consulenza dedicata. Dai anche un'occhiata al mio profilo per capire come posso aiutare concretamente la tua azienda o startup a crescere e a modernizzarsi.

Scenario pre-concurrency: le operazioni batch sequenziali in Laravel 9/10

Immaginiamo alcuni scenari comuni in cui l'esecuzione sequenziale può diventare problematica.

Esempio 1: Invio di notifiche multiple a utenti

Supponiamo di dover inviare una notifica importante a tutti gli utenti attivi di un'applicazione. Un approccio sequenziale potrebbe essere:

// In un comando Artisan o un Service (Laravel 9/10 style)
use App\Models\User;
use App\Notifications\ImportantBusinessUpdate; // Ipotetica notifica
use Illuminate\Support\Facades\Log;

// ...
$users = User::where('is_active', true)->get();
$startTime = microtime(true);
$processedCount = 0;

foreach ($users as $user) {
    try {
        $user->notify(new ImportantBusinessUpdate());
        $processedCount++;
        // Log::info("Notifica inviata a {$user->email}");
    } catch (\Exception $e) {
        Log::error("Errore invio notifica a {$user->email}: " . $e->getMessage());
    }
    // Se ci sono migliaia di utenti, questo loop può durare molto tempo,
    // specialmente se l'invio email (anche se in coda) ha un piccolo overhead.
}

$duration = microtime(true) - $startTime;
Log::info("Invio notifiche completato a {$processedCount} utenti in {$duration} secondi.");

Se ogni notifica impiega anche solo mezzo secondo per essere processata (inclusa l'interazione con il sistema di code, se usato), inviare 10.000 notifiche richiederebbe quasi un'ora e mezza!

Esempio 2: Chiamate multiple a un'API esterna

Immagina di dover arricchire i dati di N prodotti nel tuo database chiamando un'API esterna per ognuno.

// In un Service (Laravel 9/10 style)
use Illuminate\Support\Facades\Http; // Uso base dell'HTTP Client
use App\Models\Product;
use Illuminate\Support\Facades\Log;

// ...
$products = Product::whereNull('external_data_enriched_at')->take(100)->get();
$startTime = microtime(true);
$updatedCount = 0;

foreach ($products as $product) {
    try {
        $response = Http::timeout(5)->get("https://api.thirdparty.com/v1/product-info/{$product->external_id}");

        if ($response->successful()) {
            $product->external_data = $response->json();
            $product->external_data_enriched_at = now();
            $product->save();
            $updatedCount++;
        } else {
            Log::warning("Fallimento API per prodotto {$product->id}: " . $response->status());
        }
    } catch (\Illuminate\Http\Client\ConnectionException $e) {
        Log::error("Errore di connessione API per prodotto {$product->id}: " . $e->getMessage());
        // Potresti voler interrompere o riprovare dopo un po'
        break; // Esempio drastico: interrompe al primo errore di connessione
    } catch (\Exception $e) {
        Log::error("Errore generico per prodotto {$product->id}: " . $e->getMessage());
    }
}

$duration = microtime(true) - $startTime;
Log::info("Arricchimento dati completato per {$updatedCount} prodotti in {$duration} secondi.");

Se ogni chiamata API impiega in media 1 secondo (tra latenza di rete e tempo di risposta del server esterno), arricchire 100 prodotti richiederebbe almeno 100 secondi, ovvero più di un minuto e mezzo. Questo potrebbe essere accettabile per un comando Artisan notturno, ma proibitivo per una richiesta web.

Limiti dell'approccio sequenziale:

  • Tempi di esecuzione lunghi: scalano linearmente con il numero di operazioni.
  • Blocco del processo principale: un comando Artisan rimane occupato per l'intera durata; una richiesta web andrebbe sicuramente in timeout.
  • Scarsa reattività: l'applicazione non può fare altro mentre attende il completamento di queste lunghe operazioni.

Modernizzare con Illuminate\Support\Facades\Concurrency (introdotto in Laravel 11, per L12)

Il componente Concurrency di Laravel (disponibile tramite il facade Illuminate\Support\Facades\Concurrency) permette di eseguire un insieme di task in modo concorrente, attendendo poi il loro completamento. Questo è particolarmente efficace per operazioni I/O-bound (come chiamate HTTP, interazioni con il database, operazioni sul filesystem), dove il processo spenderebbe molto tempo in attesa.

Driver di Concorrenza: Laravel supporta diversi driver per la concorrenza, configurabili tramite la variabile d'ambiente CONCURRENCY_DRIVER (default async):

  • async: Utilizza le fibre (PHP fibers) se disponibili (PHP 8.1+ e estensione fibers installata) per una concorrenza leggera e cooperativa all'interno di un singolo processo. Se le fibre non sono disponibili, ripiega sull'utilizzo di proc_open per eseguire task in processi separati, il che può avere un overhead maggiore ma funziona su più versioni di PHP.
  • fork: Utilizza l'estensione PHP pcntl per creare processi figli reali (forking). Questo può permettere vero parallelismo su sistemi multi-core, ed è spesso più adatto per task CPU-bound, ma richiede l'estensione pcntl (generalmente non disponibile su Windows o in alcuni ambienti PHP-FPM restrittivi).
  • fake: Un driver per il testing, che esegue i task sequenzialmente ma permette di asserire sulle operazioni di concorrenza.

Per la maggior parte delle operazioni I/O-bound come chiamate API o notifiche, il driver async con le fibre (se disponibili) è una scelta eccellente e performante.

1. Utilizzo di Concurrency::run()

Il metodo Concurrency::run() accetta un array di closure o oggetti invokable (i tuoi task) e li esegue in modo concorrente. Ritorna un array con i risultati di ogni task, nello stesso ordine in cui i task sono stati definiti.

Refactoring Esempio 1: Invio Notifiche Concorrente

// In un comando Artisan o un Service (Laravel 11/12 style)
use App\Models\User;
use App\Notifications\ImportantBusinessUpdate;
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Log;
use Throwable; // Per il catch generico

// ...
$users = User::where('is_active', true)->get(); // Supponiamo N utenti
$startTime = microtime(true);
$tasks = [];

foreach ($users as $user) {
    // Ogni task è una closure. Puoi anche usare classi Invokable.
    $tasks[] = function () use ($user) {
        try {
            $user->notifyNow(new ImportantBusinessUpdate()); // Usiamo notifyNow per l'esecuzione immediata nel contesto del task
            // Nota: se ImportantBusinessUpdate implementa ShouldQueue, notifyNow la processerà
            // immediatamente nel task concorrente, non la invierà alla coda principale.
            // Se vuoi inviarla alla coda principale da qui, usa $user->notify().
            return ['user_id' => $user->id, 'status' => 'success'];
        } catch (Throwable $e) { // Cattura qualsiasi eccezione per non bloccare gli altri task
            Log::error("Errore invio notifica (concorrente) a utente {$user->id}: " . $e->getMessage());
            return ['user_id' => $user->id, 'status' => 'failed', 'error' => $e->getMessage()];
        }
    };
}

// Esegui i task in concorrenza.
// Il secondo argomento è un callback opzionale eseguito per ogni task completato (anche se ha fallito internamente).
// Il terzo argomento è un callback opzionale per eccezioni non catturate *all'interno* delle closure dei task.
// Il quarto argomento è un timeout generale per tutti i task (in millisecondi).
$results = Concurrency::run(
    $tasks,
    function (array $result, int $index) { // $result è ciò che la closure del task ha ritornato
        if ($result['status'] === 'success') {
            // Log::info("Task notifica [{$index}] per utente {$result['user_id']} completato.");
        }
    },
    function (Throwable $e, int $index) {
        Log::critical("Eccezione non gestita dal task di notifica [{$index}]: " . $e->getMessage(), ['exception' => $e]);
    },
    timeout: 60000 // Timeout di 60 secondi per l'intero batch
);

$duration = microtime(true) - $startTime;
$successfulNotifications = count(array_filter($results, fn($r) => is_array($r) && $r['status'] === 'success'));
$failedNotifications = count($users) - $successfulNotifications;

Log::info("Invio notifiche (concorrente) completato. Successi: {$successfulNotifications}, Fallimenti: {$failedNotifications}. Durata: {$duration} secondi.");

Con questo approccio, se hai 100 utenti e ogni notifica (nel suo task isolato) impiega 0.5s, il tempo totale non sarà 50s, ma molto più vicino al tempo dell'operazione singola più lunga (o limitato dal numero di fibre/processi concorrenti che Laravel può gestire, configurabile tramite config('queue.connections.database.concurrent_tasks') per il driver async con proc_open o internamente per le fibre).

Refactoring Esempio 2: Chiamate Multiple a un'API Esterna in Concorrenza Un caso d'uso molto comune per la concorrenza è l'esecuzione di multiple chiamate a API esterne. Per approfondire come strutturare queste chiamate singolarmente in modo robusto, ti consiglio di leggere la mia guida al refactoring delle integrazioni API esterne in Laravel. Qui, ci concentreremo sull'eseguirle in concorrenza.

// In un Service (Laravel 11/12 style)
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Http; // Usiamo l'HTTP Client di Laravel
use Illuminate\Support\Facades\Log;
use App\Models\Product; // Il tuo model
use Throwable;

// ...
$productIdsToEnrich = Product::whereNull('external_data_enriched_at')->limit(50)->pluck('id', 'external_api_id');
$startTime = microtime(true);
$apiTasks = [];

// Supponiamo che external_api_id sia l'ID da usare con l'API esterna
foreach ($productIdsToEnrich as $externalApiId => $internalProductId) {
    $apiTasks[$internalProductId] = function () use ($externalApiId, $internalProductId) { // Usiamo l'ID interno come chiave per facilitare il matching dei risultati
        try {
            // Assicurati che il tuo HTTP Client sia configurato con timeout appropriati
            // per evitare che un singolo task blocchi tutto per troppo tempo.
            $response = Http::timeout(10) // Timeout per questa specifica chiamata
                              ->acceptJson()
                              ->get("https://api.thirdparty.com/v1/product-info/{$externalApiId}");

            if ($response->successful()) {
                return ['product_id' => $internalProductId, 'status' => 'success', 'data' => $response->json()];
            } else {
                Log::warning("Fallimento API (concorrente) per external ID {$externalApiId} (prodotto ID {$internalProductId}): " . $response->status());
                return ['product_id' => $internalProductId, 'status' => 'failed', 'http_status' => $response->status()];
            }
        } catch (Throwable $e) {
            Log::error("Errore chiamata API (concorrente) per external ID {$externalApiId} (prodotto ID {$internalProductId}): " . $e->getMessage());
            return ['product_id' => $internalProductId, 'status' => 'exception', 'error' => $e->getMessage()];
        }
    };
}

// Esegui i task, impostando un numero massimo di task concorrenti
// Questo è configurabile globalmente in config/concurrency.php o per driver.
// Qui è un esempio di come potresti volerlo limitare programmaticamente se necessario,
// anche se Concurrency::run gestisce il pool internamente.
// La configurazione avviene in config/concurrency.php -> stores.async.concurrent_tasks
$results = Concurrency::run($apiTasks, timeout: 45000); // Timeout generale 45s

$updatedCount = 0;
foreach ($results as $internalProductId => $result) { // Se abbiamo usato chiavi associative per $apiTasks
    if (is_array($result) && $result['status'] === 'success' && isset($result['data'])) {
        $product = Product::find($internalProductId);
        if ($product) {
            $product->external_data = $result['data'];
            $product->external_data_enriched_at = now();
            $product->save();
            $updatedCount++;
        }
    }
    // Altrimenti, gestisci i fallimenti o le eccezioni come loggato all'interno del task
}

$duration = microtime(true) - $startTime;
Log::info("Arricchimento dati (concorrente) completato. Prodotti aggiornati: {$updatedCount}. Durata: {$duration} secondi.");

Nota sul Timeout: Il timeout in Concurrency::run() è un timeout generale per il completamento di tutti i task. Ogni chiamata HTTP all'interno dei task dovrebbe avere il suo timeout() individuale per evitare che un singolo task lento blocchi l'intero batch indefinitamente.

2. Utilizzo di Concurrency::defer() e Concurrency::awaitall()

Per scenari più dinamici o per gestire un pool di task a cui se ne aggiungono altri nel tempo, puoi usare Concurrency::defer() per accodare un task al pool di concorrenza senza bloccare l'esecuzione. Successivamente, Concurrency::awaitall() attenderà che tutti i task nel pool (o un array specifico di PendingTask ritornati da defer) siano completati.

use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Log;
use App\Models\DataToProcess; // Esempio

// ...
$dataItems = DataToProcess::cursor(); // Usa un cursore per gestire grandi set di dati
$pendingTasks = [];
$startTime = microtime(true);

foreach ($dataItems as $item) {
    // defer() aggiunge il task al pool e ritorna un'istanza di PendingTask
    $pendingTasks[] = Concurrency::defer(function () use ($item) {
        try {
            // Simula un'operazione I/O-bound o CPU-bound leggera
            // Per operazioni CPU-bound reali, considera il driver 'fork' e le sue implicazioni
            sleep(random_int(1,3)); // Simula lavoro
            $result = "Processed: " . $item->name;
            // Log::info($result);
            return ['item_id' => $item->id, 'result' => $result];
        } catch (Throwable $e) {
            Log::error("Errore processando item {$item->id}: " . $e->getMessage());
            return ['item_id' => $item->id, 'error' => $e->getMessage()];
        }
    });

    // Potresti voler limitare il numero di task 'deferiti' contemporaneamente
    // se il set di dati è enorme, per non esaurire la memoria.
    // Ad esempio, ogni N defer, fai un awaitall parziale.
    if (count($pendingTasks) % 50 === 0) { // Esempio: attendi ogni 50 task
        Log::info("In attesa di un batch parziale di " . count($pendingTasks) . " task...");
        $partialResults = Concurrency::awaitall($pendingTasks, timeout: 120000); // Timeout 2 minuti
        // Processa $partialResults qui...
        $pendingTasks = []; // Resetta l'array per il prossimo batch
    }
}

// Attendi i task rimanenti
if (!empty($pendingTasks)) {
    Log::info("In attesa dei task rimanenti (" . count($pendingTasks) . ")...");
    $finalResults = Concurrency::awaitall($pendingTasks, timeout: 120000);
    // Processa $finalResults qui...
}

$duration = microtime(true) - $startTime;
Log::info("Processo batch con defer/awaitall completato in {$duration} secondi.");

Limiti e Considerazioni sulla Concorrenza in PHP/Laravel

  • Non è (sempre) vero parallelismo: con il driver async basato su fibre o proc_open, la concorrenza è gestita all'interno di un singolo processo PHP (o più processi figli con proc_open). Questo è eccellente per task I/O-bound (attesa di rete, filesystem, database) perché il processo può passare ad altri task mentre uno è in attesa. Per task genuinamente CPU-bound, il driver fork (che usa pcntl) può offrire vero parallelismo su macchine multi-core, ma ha requisiti specifici (estensione pcntl, non disponibile su Windows).
  • Overhead: per task molto piccoli e veloci, l'overhead della gestione della concorrenza potrebbe superare i benefici. Valuta caso per caso.
  • Gestione delle risorse: troppi task concorrenti (specialmente se proc_open crea molti processi o se il driver fork è usato massivamente) possono esaurire le risorse del server (CPU, memoria). È possibile configurare il numero massimo di task concorrenti in config/concurrency.php.
  • Condivisione dello stato e race conditions: i task concorrenti dovrebbero essere il più possibile indipendenti. La condivisione di stato mutabile tra task concorrenti può portare a race conditions. Le fibre mitigano alcuni di questi problemi rispetto al threading tradizionale, ma è sempre bene progettare i task per essere stateless o per usare meccanismi di sincronizzazione sicuri se la condivisione è inevitabile.
  • Gestione delle eccezioni: come mostrato, è importante gestire le eccezioni all'interno di ogni task per evitare che un singolo fallimento blocchi l'intero batch, a meno che non sia il comportamento desiderato. Concurrency::run() e awaitall() possono anche avere callback globali per il catch.

Sinergia con il Process Facade

Per operazioni batch che richiedono l'esecuzione di comandi CLI esterni (es. script Python, tool di elaborazione immagini come ImageMagick), il Process facade (introdotto in Laravel 10) può essere utilizzato all'interno dei task gestiti da Concurrency::run(). Questo permette di orchestrare più processi esterni in modo concorrente.

// Esempio concettuale
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Process;

$filesToConvert = ['image1.jpg', 'image2.png', 'image3.webp'];
$conversionTasks = [];

foreach ($filesToConvert as $file) {
    $conversionTasks[] = function () use ($file) {
        $result = Process::timeout(60)->run("convert {$file} -resize 50% output/{$file}");
        if ($result->successful()) {
            return "Conversione di {$file} riuscita.";
        }
        return "Fallimento conversione {$file}: " . $result->errorOutput();
    };
}
$conversionResults = Concurrency::run($conversionTasks);
dump($conversionResults);

Benefici del Refactoring per la tua Impresa

Adottare il componente Concurrency per le operazioni batch che erano sequenziali in Laravel 9/10 porta a:

  • Drastica riduzione dei tempi di esecuzione: specialmente per task I/O-bound.
  • Migliore utilizzo delle risorse del server: il server non rimane inattivo durante le attese I/O.
  • Applicazioni e comandi Artisan più reattivi: migliora l'esperienza utente e l'efficienza operativa.
  • Capacità di gestire carichi di lavoro più grandi: permette di processare più dati o più richieste nello stesso lasso di tempo.
  • Codice più moderno e allineato: sfrutta le ultime innovazioni del framework Laravel.

Il Ruolo del Programmatore Laravel Esperto

Identificare quali operazioni batch beneficiano maggiormente della concorrenza, scegliere il driver giusto, gestire correttamente lo stato, le eccezioni e i timeout, e integrare il tutto in un'applicazione esistente richiede esperienza. Come programmatore laravel esperto e senior laravel developer, posso assistere la tua impresa in:

  • Analisi delle performance delle attuali operazioni batch.
  • Refactoring del codice sequenziale per utilizzare Concurrency::run() o defer()/awaitall().
  • Ottimizzazione dei task per la concorrenza.
  • Implementazione di strategie di logging e monitoraggio per i processi concorrenti.
  • Scrittura di test per i nuovi componenti concorrenti.

Per un approfondimento sul mio approccio e sulla mia esperienza ventennale, ti invito a visitare la mia pagina Chi Sono.

Sfruttare la concorrenza in Laravel 12 non è solo un tecnicismo; è una strategia per rendere il tuo business più efficiente, scalabile e capace di rispondere rapidamente alle esigenze del mercato.

Se la tua applicazione Laravel è frenata da operazioni batch lente e vuoi esplorare come la concorrenza possa rivoluzionare le tue performance, contattami per una consulenza mirata.

Ultima modifica: Giovedì 20 Febbraio 2025, alle 13:04