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
- Rendimiento Mejorado: Podemos iniciar múltiples peticiones simultáneamente sin esperar a que cada una termine.
- Mejor Gestión de Recursos: El servidor puede manejar más peticiones concurrentes.
- 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
- API más Rica: Guzzle ofrece una API más completa para el manejo de promesas, similar a JavaScript.
- Middleware: Permite agregar middleware para modificar requests/responses.
- Pool de Conexiones: Manejo nativo de pools de conexiones para controlar la concurrencia.
- 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: