7 minutos de lectura

Promesas en PHP: HttpClient de Symfony y Peticiones Asíncronas

Puntos a ver ...

Promesas en PHP: HttpClient de Symfony y Peticiones Asíncronas

Las promesas son un patrón de diseño que nos permite manejar operaciones asíncronas de manera elegante. En PHP, aunque el lenguaje no tiene soporte nativo para promesas como JavaScript, podemos implementar este comportamiento usando el componente HttpClient de Symfony.

Concepto Básico de Promesas

Una promesa representa un valor que puede estar disponible ahora, en el futuro, o nunca. Es especialmente útil cuando realizamos operaciones que pueden tomar tiempo, como peticiones HTTP.

Instalación del Componente

Primero, necesitamos instalar el componente HttpClient de Symfony:

composer require symfony/http-client

Ejemplo Básico de Petición Asíncrona

Veamos un ejemplo sencillo de cómo realizar una petición asíncrona:

use Symfony\Component\HttpClient\HttpClient;

class WeatherService
{
    private $client;
    
    public function __construct()
    {
        $this->client = HttpClient::create();
    }
    
    public function getWeatherAsync(string $city): string
    {
        // Iniciamos la petición asíncrona
        $response = $this->client->request('GET', 
            "https://api.weather.com/v1/current?city={$city}"
        );
        
        // La petición se ha iniciado pero no se ha completado
        // El contenido se recuperará cuando lo necesitemos
        return $response->getContent();
    }
}

Manejo de Múltiples Peticiones

La verdadera potencia de las promesas se revela cuando necesitamos hacer múltiples peticiones:

class CityWeatherService
{
    private $client;
    
    public function __construct()
    {
        $this->client = HttpClient::create();
    }
    
    public function getMultipleCitiesWeather(array $cities): array
    {
        $responses = [];
        
        // Iniciamos todas las peticiones sin esperar las respuestas
        foreach ($cities as $city) {
            $responses[$city] = $this->client->request(
                'GET',
                "https://api.weather.com/v1/current?city={$city}"
            );
        }
        
        $results = [];
        
        // Procesamos las respuestas cuando estén disponibles
        foreach ($responses as $city => $response) {
            try {
                $results[$city] = $response->toArray();
            } catch (TransportExceptionInterface $e) {
                $results[$city] = ['error' => $e->getMessage()];
            }
        }
        
        return $results;
    }
}

Ejemplo Práctico en un Controlador Symfony

Veamos cómo implementar esto en un controlador real:

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;

class WeatherController extends AbstractController
{
    private $weatherService;
    
    public function __construct(CityWeatherService $weatherService)
    {
        $this->weatherService = $weatherService;
    }
    
    #[Route('/weather/cities', name: 'get_cities_weather')]
    public function getCitiesWeather(): JsonResponse
    {
        $cities = ['Madrid', 'Paris', 'London', 'Berlin'];
        
        $weatherData = $this->weatherService->getMultipleCitiesWeather($cities);
        
        return $this->json($weatherData);
    }
}

Manejando Timeouts y Errores

Es importante manejar adecuadamente los timeouts y errores en peticiones asíncronas:

use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Component\HttpClient\Exception\TimeoutException;

class RobustWeatherService
{
    private $client;
    
    public function __construct()
    {
        $this->client = HttpClient::create([
            'timeout' => 3.0,
            'max_duration' => 10.0,
        ]);
    }
    
    public function getWeatherWithTimeout(string $city): array
    {
        try {
            $response = $this->client->request('GET', 
                "https://api.weather.com/v1/current?city={$city}"
            );
            
            return $response->toArray(false);
        } catch (TimeoutException $e) {
            return ['error' => 'La petición excedió el tiempo límite'];
        } catch (TransportExceptionInterface $e) {
            return ['error' => 'Error de conexión: ' . $e->getMessage()];
        }
    }
}

Beneficios del Uso de Promesas

  1. Rendimiento Mejorado: Podemos iniciar múltiples peticiones simultáneamente sin esperar a que cada una termine.
  2. Mejor Gestión de Recursos: El servidor puede manejar más peticiones concurrentes.
  3. Código más Limpio: Evitamos el “callback hell” común en código asíncrono.

Consideraciones Importantes

  • Las promesas en PHP son diferentes a las de JavaScript. No tenemos métodos .then() o .catch().
  • El componente HttpClient maneja internamente la asincronía.
  • Es importante establecer timeouts adecuados para evitar bloqueos.
  • Siempre debemos manejar los errores apropiadamente.

Promesas con Guzzle HTTP Client

Guzzle ofrece una implementación robusta de promesas que sigue la especificación Promises/A+. Veamos cómo implementarlo:

Instalación de Guzzle

composer require guzzlehttp/guzzle

Ejemplo Básico con Promesas en Guzzle

use GuzzleHttp\Client;
use GuzzleHttp\Promise\Utils;

class GuzzleWeatherService
{
    private $client;
    
    public function __construct()
    {
        $this->client = new Client([
            'base_uri' => 'https://api.weather.com/v1/',
            'timeout'  => 5.0,
        ]);
    }
    
    public function getWeatherAsync(array $cities): array
    {
        $promises = [];
        
        // Crear promesas para cada ciudad
        foreach ($cities as $city) {
            $promises[$city] = $this->client->getAsync("current?city={$city}")
                ->then(
                    // OnSuccess handler
                    function ($response) {
                        return json_decode($response->getBody(), true);
                    },
                    // OnError handler
                    function ($exception) {
                        return ['error' => $exception->getMessage()];
                    }
                );
        }
        
        // Esperar a que todas las promesas se resuelvan
        return Utils::unwrap($promises);
    }
}

Implementación en un Servicio de Symfony

use GuzzleHttp\Client;
use GuzzleHttp\Promise\Utils;
use GuzzleHttp\Exception\RequestException;
use Psr\Log\LoggerInterface;

class WeatherApiService
{
    private $client;
    private $logger;
    
    public function __construct(LoggerInterface $logger)
    {
        $this->client = new Client([
            'base_uri' => 'https://api.weather.com/v1/',
            'timeout'  => 5.0,
            'headers' => [
                'Accept' => 'application/json',
                'User-Agent' => 'WeatherApp/1.0',
            ]
        ]);
        $this->logger = $logger;
    }
    
    public function getMultipleWeatherReports(array $locations): array
    {
        $promises = [];
        
        foreach ($locations as $location) {
            $promises[$location] = $this->client
                ->getAsync("weather?location={$location}")
                ->then(
                    function ($response) use ($location) {
                        $data = json_decode($response->getBody(), true);
                        return [
                            'location' => $location,
                            'temperature' => $data['temperature'] ?? null,
                            'conditions' => $data['conditions'] ?? null,
                            'status' => 'success'
                        ];
                    },
                    function (RequestException $e) use ($location) {
                        $this->logger->error('Weather API error', [
                            'location' => $location,
                            'error' => $e->getMessage()
                        ]);
                        
                        return [
                            'location' => $location,
                            'status' => 'error',
                            'message' => $e->getMessage()
                        ];
                    }
                );
        }
        
        try {
            // Resolver todas las promesas de manera concurrente
            return Utils::settle($promises)->wait();
        } catch (\Exception $e) {
            $this->logger->critical('Critical error in weather service', [
                'error' => $e->getMessage()
            ]);
            throw $e;
        }
    }
}

Uso en un Controlador

class WeatherController extends AbstractController
{
    private $weatherService;
    
    public function __construct(WeatherApiService $weatherService)
    {
        $this->weatherService = $weatherService;
    }
    
    #[Route('/weather/batch', name: 'batch_weather', methods: ['POST'])]
    public function batchWeather(Request $request): JsonResponse
    {
        $locations = $request->toArray()['locations'] ?? [];
        
        if (empty($locations)) {
            return $this->json([
                'error' => 'Debe proporcionar al menos una ubicación'
            ], 400);
        }
        
        try {
            $results = $this->weatherService->getMultipleWeatherReports($locations);
            
            return $this->json([
                'status' => 'success',
                'data' => $results
            ]);
            
        } catch (\Exception $e) {
            return $this->json([
                'status' => 'error',
                'message' => 'Error al obtener datos meteorológicos'
            ], 500);
        }
    }
}

Manejo de Pool de Promesas

Guzzle también permite manejar pools de promesas para cuando necesitamos controlar la concurrencia:

use GuzzleHttp\Pool;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Request;

class WeatherPoolService
{
    private $client;
    private const CONCURRENCY = 5;
    
    public function __construct()
    {
        $this->client = new Client();
    }
    
    public function fetchWeatherBatch(array $cities): array
    {
        $results = [];
        
        $requests = function ($cities) {
            foreach ($cities as $city) {
                yield new Request('GET', "https://api.weather.com/v1/current?city={$city}");
            }
        };
        
        $pool = new Pool($this->client, $requests($cities), [
            'concurrency' => self::CONCURRENCY,
            'fulfilled' => function ($response, $index) use (&$results, $cities) {
                $results[$cities[$index]] = json_decode($response->getBody(), true);
            },
            'rejected' => function ($reason, $index) use (&$results, $cities) {
                $results[$cities[$index]] = [
                    'error' => $reason->getMessage()
                ];
            },
        ]);
        
        // Ejecutar el pool de peticiones
        $pool->promise()->wait();
        
        return $results;
    }
}

Ventajas de Guzzle sobre HttpClient

  1. API más Rica: Guzzle ofrece una API más completa para el manejo de promesas, similar a JavaScript.
  2. Middleware: Permite agregar middleware para modificar requests/responses.
  3. Pool de Conexiones: Manejo nativo de pools de conexiones para controlar la concurrencia.
  4. Retry: Políticas de reintento incorporadas.

Consideraciones de Rendimiento

class OptimizedWeatherService
{
    private $client;
    
    public function __construct()
    {
        $this->client = new Client([
            'connect_timeout' => 3.0,
            'timeout' => 5.0,
            'http_errors' => false,
            'pool_size' => 25,
            'keep_alive' => true
        ]);
    }
    
    // ... resto del código
}

La elección entre HttpClient de Symfony y Guzzle dependerá de tus necesidades específicas. HttpClient es más ligero y está integrado con Symfony, mientras que Guzzle ofrece más características y una API más rica para el manejo de promesas.

Recursos Adicionales

Para profundizar más en el tema de promesas en PHP, te recomiendo ver este excelente tutorial:

Tutorial sobre Promesas en PHP