Статья о реализации параллельных SOAP запросов в PHP с использованием кастомного HTTP транспорта и Guzzle promises
? AI Assistant Note: Данная статья была подготовлена с использованием AI-агента для обеспечения технической точности, полноты кода и качественного перевода. AI-агент помог в структурировании контента, проверке технических деталей и создании билингвальной версии статьи.
Введение
Добрый день!
Однажды перед нами встала необходимость выполнять параллельно SOAP запросы: запросов было много, они были достаточно тяжёлые и стандартный SOAP client по предварительной оценке должен был выполнять их неделю. Очевидно, это было связано с тем, что запросы выполнялись последовательно, что в 21 веке выглядит каким-то анахронизмом. Например, очень хотелось бы использовать возможности Guzzle Promises, как указано в документации https://docs.guzzlephp.org/en/stable/quickstart.html#concurrent-requests.
Давайте посмотрим, что мы можем с этим сделать.
Анатомия \SoapClient
Сам по себе \SoapClient представляет собой неплохой образец нарушения таких принципов SOLID, как Single Responsibility и Open/Close Principle:
он умеет и отправлять данные по HTTP, и производит маршаллинг/анмаршаллинг данных и маппинг данных. Жаль что кофе не умеет делать.
его внутренние реализации, такие как HTTP транспорт, абсолютно невозможно поменять. Нну как невозможно, почти.
Но, с другой стороны, нельзя не отметить хороший уровень абстракций для Dependency Inversion Principle: методы __doRequest() и __soapCall() представляют собой интерфейсы разных уровней абстракий: низкоуроневый интерфейс передачи HTTP сообщений и высокоуровневый интерфейс работы с объектами.
Анализ SOLID принципов
Давайте разберём каждый принцип SOLID в контексте \SoapClient:
Single Responsibility Principle (SRP) - Нарушение ❌
\SoapClient нарушает принцип единственной ответственности, выполняя сразу несколько задач:
HTTP коммуникация: отправка запросов и получение ответов
Маршаллинг данных: преобразование PHP объектов в XML
Анмаршаллинг данных: преобразование XML обратно в PHP объекты
Маппинг типов: преобразование SOAP типов в PHP типы
Кэширование WSDL: загрузка и кэширование схемы сервиса
Это делает класс сложным для тестирования и модификации.
Open/Closed Principle (OCP) - Нарушение ❌
\SoapClient закрыт для расширения, но открыт для модификации. Внутренние компоненты (HTTP транспорт, парсеры) жёстко связаны с основным классом. Невозможно:
Заменить HTTP транспорт без изменения исходного кода
Добавить поддержку новых протоколов
Изменить логику кэширования
Liskov Substitution Principle (LSP) - Соблюдение ✅
\SoapClient можно наследовать и заменять базовый класс без нарушения функциональности. Это единственный принцип, который соблюдается корректно.
Interface Segregation Principle (ISP) - Частичное нарушение ⚠️
\SoapClient предоставляет единый интерфейс для всех операций, но клиенты могут использовать только часть функциональности. Например, если нужен только HTTP транспорт, всё равно приходится загружать всю SOAP логику.
Dependency Inversion Principle (DIP) - Соблюдение ✅
\SoapClient правильно зависит от абстракций через методы __doRequest() и __soapCall(), а не от конкретных реализаций. Это позволяет подменять поведение через наследование.
Анализ интерфейсов __doRequest() и __soapCall()
Методы __doRequest() и __soapCall() представляют собой отличный пример применения Dependency Inversion Principle:
// Низкоуровневый интерфейс HTTP коммуникации
public function __doRequest(
string $request, // XML запрос
string $location, // HTTP URL
string $action, // SOAP Action
int $version, // SOAP версия
bool $oneWay = false // Ожидать ли ответ
): ?string;
// Высокоуровневый интерфейс работы с объектами
public function __soapCall(
string $name, // Имя метода
array $args, // Аргументы
array|null $options, // Опции
$inputHeaders, // Входящие заголовки
&$outputHeaders // Исходящие заголовки
): mixed;
Это разделение позволяет:
Тестировать HTTP слой отдельно от SOAP логики
Подменять HTTP транспорт без изменения SOAP логики
Архитектурные ограничения и их последствия
Основные проблемы архитектуры \SoapClient:
Последовательное выполнение: Все запросы выполняются синхронно
Жёсткая связанность: HTTP транспорт встроен в класс
Отсутствие композиции: Невозможно комбинировать разные транспорты
Ограниченная расширяемость: Сложно добавить новую функциональность
Эти ограничения приводят к:
Низкой производительности при множественных запросах
Сложности тестирования из-за жёсткой связанности
Невозможности переиспользования HTTP логики в других контекстах
Что же, не будем критиковать пакет SOAP, это легаси-код и он такой, какой он есть и очевидно были определённые причины сделать его таким. Лучше посмотрим что мы можем с этим сделать.
Кастомный HTTP транспорт
Давайте создадим проект для наших упражнений:
mkdir php-soap-concurrent
cd php-soap-concurrent
Отлично! Раз мы работаем с PHP в 21 веке решительно нет ни одной причины не использовать PSR стандарты для HTTP коммуникаций, такие как PSR-7: HTTP message interfaces и PSR-18: HTTP Client. Давайте же включим их в проект:
composer require psr/http-message psr/http-client psr/http-factories
Ну или как-то так.
Интеграция с PSR стандартами
Наше решение полностью совместимо с современными PSR стандартами:
PSR-7: HTTP Message Interfaces
PSR-7 определяет стандартные интерфейсы для HTTP сообщений, что позволяет:
Унифицировать работу с HTTP запросами и ответами
Интегрироваться с любыми PSR-7 совместимыми библиотеками
Тестировать HTTP слой независимо от SOAP логики
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
// Наш транспорт использует PSR-7 интерфейсы
class GuzzlePromiseTransport implements Transport, RequestFactoryInterface
{
protected RequestInterface $request;
protected ResponseInterface $response;
public function createRequest(string $method, $uri): RequestInterface
{
// Создание PSR-7 совместимого запроса
}
}
PSR-18: HTTP Client
PSR-18 определяет стандартный интерфейс для HTTP клиентов, что обеспечивает:
Взаимозаменяемость HTTP клиентов
Единообразие API для всех HTTP операций
Совместимость с различными HTTP библиотеками
use Psr\Http\Client\ClientInterface;
// Guzzle автоматически реализует PSR-18
$client = new \GuzzleHttp\Client(); // implements ClientInterface
// Наш SOAP клиент работает с любым PSR-18 клиентом
$soap = new GuzzlePromiseSoapClient($wsdl, $options, $client);
Преимущества PSR интеграции
Стандартизация: Единый подход к HTTP коммуникациям
Совместимость: Работа с любыми PSR-совместимыми библиотеками
Тестируемость: Легкое создание моков и стабов
Расширяемость: Простое добавление новых HTTP клиентов
Производительность: Оптимизация через специализированные клиенты
Отлично! Теперь мы готовы к имплементации нашего HTTP транспорта.
Интерфейс
Каким же он будет? Предлагаю не переизобретать колесо и взять уже готовый \SoapClient::__doRequest(): это позволит легко перенести логику из клиента в транспорт
Источник: src/Transport.php
<?php
declare(strict_types=1);
namespace AndreiStepanov\Examples\ConcurrentSoap;
interface Transport
{
/** @see \SoapClient::__doRequest() */
public function doRequest(string $request, string $location, string $action, int $version, bool $oneWay = false): ?string;
}
Великолепно! Что же, теперь давайте попробуем его использовать.
Новый старый клиент
Применим ещё один SOLID принцип Liskov Substitution Principle и создадим класс-потомок \SoapClient с обратно совместимым интерфейсом конструктора:
Источник: src/SoapClient.php
<?php
namespace AndreiStepanov\Examples\ConcurrentSoap;
use \SoapClient as BaseSoapClient;
/** @see \SoapClient */
class SoapClient extends BaseSoapClient
{
protected ?Transport $transport;
/** @see \SoapClient::__construct() */
public function __construct(?string $wsdl, array $options = [], ?Transport $transport = null)
{
if (empty($options['transport']) && $transport === null) {
throw new \InvalidArgumentException('Transport is required');
}
$this->transport = $options['transport'] ?? $transport;
parent::__construct($wsdl, $options);
}
/** @see \SoapClient::__doRequest() */
public function __doRequest(string $request, string $location, string $action, int $version, bool $oneWay = false): ?string
{
if ($this->transport === null) {
return parent::__doRequest($request, $location, $action, $version, $oneWay);
}
$resposne = $this->transport->doRequest($request, $location, $action, $version, $oneWay);
if ($oneWay) {
return null;
} else {
return $resposne;
}
}
}
Отлично! Теперь мы можем передавать в SoapClient нашу имплементацию Transport, которую мы можем полностью контролировать!
use AndreiStepanov\Examples\ConcurrentSoap;
$transport = new MyTransportImpl();
$soap = new SoapClient(wsdl: $wsdl, options: $options, transport: $transport);
// или же для полной обратной совместимости интерфейса конструктора
$options['transport'] = $transport;
$soap = new SoapClient(wsdl: $wsdl, options: $options);
Осталось всего ничего - добавить имплементацию.
Имплементация
Так как у нас уже есть великолепная библиотека Guzzle https://docs.guzzlephp.org/en/stable/quickstart.html#concurrent-requests , которая к тому же является PSR-совместимой с нашими стандартами, то нет ни одной причины не использовать её.
Итак, наша цель - использовать Guzzle с возможностью конкуретных запросов. Для этого в Guzzle используется реализация Promises/A+.
Следовательно, наш транспорт должен каким-то образом возвращать не XML ответа в виде строкового значения, а объект Promise.
HttpMessageTransport - Транспорт для HTTP ответов
Для работы с уже полученными HTTP ответами создадим специальный транспорт:
Источник: src/HttpMessageTransport.php
<?php
declare(strict_types=1);
namespace AndreiStepanov\Examples\ConcurrentSoap;
use Psr\Http\Message\ResponseInterface;
class HttpMessageTransport implements Transport
{
public ResponseInterface $response;
public function __construct(ResponseInterface $response)
{
$this->response = $response;
}
public function doRequest(string $request, string $location, string $action, int $version, bool $oneWay = false): ?string
{
$this->response->getBody()->rewind();
return $this->response->getBody()->getContents();
}
}
Этот транспорт позволяет работать с уже полученными HTTP ответами, что критично для асинхронной обработки.
HttpResponseSoapClient - SOAP клиент для HTTP ответов
Для обработки HTTP ответов в контексте SOAP создадим специализированный клиент:
Источник: src/HttpResponseSoapClient.php
<?php
declare(strict_types=1);
namespace AndreiStepanov\Examples\ConcurrentSoap;
use Psr\Http\Message\ResponseInterface;
class HttpResponseSoapClient extends SoapClient
{
/** @var HttpMessageTransport */
public ?Transport $transport = null;
public function __construct(?string $wsdl, array $options = [], ?ResponseInterface $response = null) {
if (empty($options['response']) && $response === null) {
throw new \InvalidArgumentException('Response is required');
}
parent::__construct(
wsdl: $wsdl,
options: ['uri' => 'http://127.0.0.1', 'location' => 'http://127.0.0.1'],
transport: new HttpMessageTransport($options['response'] ?? $response),
);
}
}
Этот клиент специально предназначен для обработки HTTP ответов в контексте promise callbacks.
Обработка данных и интеграция с Promise
Ключевая особенность нашего решения - интеграция с Guzzle Promises для асинхронной обработки:
// В GuzzlePromiseTransport
$this->promise = $this->client->sendAsync($this->request, $this->getClientOptions())->then(
onFulfilled: function (ResponseInterface $response): mixed {
$this->response = $response;
return (new HttpResponseSoapClient($this->wsdl, $this->options, $response))
->__soapCall(...$this->soapCall);
},
);
Это позволяет:
Асинхронно обрабатывать HTTP ответы
Интегрировать SOAP логику с Promise callbacks
Сохранять совместимость с существующим SOAP API
Обработка ошибок и исключений
Наше решение включает полную обработку ошибок:
// Обработка ошибок в promise chain
$results = Utils::settle($promises)->wait();
foreach ($results as $key => $result) {
if ($result['state'] === PromiseInterface::FULFILLED) {
// Успешная обработка
echo "✅ {$key}: OK\n";
} else {
// Обработка ошибок
echo "❌ {$key}: ERROR\n";
$exception = $result['reason'];
// Логирование или обработка исключения
}
}
Это обеспечивает надёжную обработку как успешных, так и неудачных запросов.
Посмотрим что мы можем с этим сделать.
Итог
Что же мы в итоге получили? А получили мы возможность создавать батч запросы к сервисам SOAP в таком виде:
Источник: test.php
<?php
require_once 'vendor/autoload.php';
use AndreiStepanov\Examples\ConcurrentSoap\GuzzlePromiseSoapClient;
use GuzzleHttp\Client;
use GuzzleHttp\Promise\PromiseInterface;
use GuzzleHttp\Promise\Utils;
$start = microtime(true);
$wsdl = 'http://webservices.oorsprong.org/websamples.countryinfo/CountryInfoService.wso?WSDL';
$options = [
'trace' => true,
'version' => \SOAP_1_2,
'cache_wsdl' => \WSDL_CACHE_BOTH,
];
$client = new Client();
// Create multiple SOAP client instances
$soap1 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap2 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap3 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap4 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap5 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap6 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap7 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
$soap8 = new GuzzlePromiseSoapClient($wsdl, $options, $client);
// Initiate all requests concurrently (non-blocking)
$promises = [
'CapitalCity_US' => $soap1->__soapCall('CapitalCity', [['sCountryISOCode' => 'US']]),
'CapitalCity_GB' => $soap2->__soapCall('CapitalCity', [['sCountryISOCode' => 'GB']]),
'CapitalCity_FR' => $soap3->__soapCall('CapitalCity', [['sCountryISOCode' => 'FR']]),
'CapitalCity_DE' => $soap4->__soapCall('CapitalCity', [['sCountryISOCode' => 'DE']]),
'CapitalCity_IT' => $soap5->__soapCall('CapitalCity', [['sCountryISOCode' => 'IT']]),
'CapitalCity_ES' => $soap6->__soapCall('CapitalCity', [['sCountryISOCode' => 'ES']]),
'CapitalCity_PT' => $soap7->__soapCall('CapitalCity', [['sCountryISOCode' => 'PT']]),
'CapitalCity_NL' => $soap8->__soapCall('CapitalCity', [['sCountryISOCode' => 'NL']]),
];
// Wait for all promises to complete (success or failure)
$results = Utils::settle($promises)->wait();
// Process results - check state for each
foreach ($results as $key => $result) {
if ($result['state'] === PromiseInterface::FULFILLED) {
// Success - process the SOAP response
echo "✅ {$key}: OK\n";
} else {
// Failure - handle the exception
echo "❌ {$key}: ERROR\n";
}
}
$end = microtime(true);
echo "Time taken: " . ($end - $start) . " seconds\n";
Дополнительные материалы
Стандарты PSR
PSR-7: HTTP Message Interfaces - Стандартные интерфейсы для HTTP сообщений
PSR-18: HTTP Client - Стандартный интерфейс для HTTP клиентов
PSR-17: HTTP Factories - Фабрики для создания HTTP сообщений
Документация Guzzle
Guzzle HTTP Client - Официальная документация
Concurrent Requests - Параллельные запросы с Guzzle
Promises/A+ - Спецификация Promises/A+
Связанные статьи
PSR Standards Best Practices - Лучшие практики использования PSR стандартов
Заключение
Весь исходный код примеров для данной статьи вы можете найти в моём GitHub репозитории https://github.com/andrei-stsiapanau/examples-php-soapclient-transports