Если вы разрабатываете библиотеки, которые выполняют HTTP-запросы, то для вас тут небольшое руководство о том, как не усложнять жизнь пользователям ваших продуктов.

С появлением концепций бессерверных и микросервисных систем, федеративных механизмов аутентификации (OpenID, SAML, …) и т.д., приём и отправка HTTP запросов приобретает особое значение в веб-приложениях.

Поскольку каждый сервис предоставляет совершенно разные API, как с точки зрения технических деталей (REST, RPC, GraphQL, …) так и в плане содержания (какие поля и какого типа данные открываются), разработчики обычно прибегают к созданию пакета Composer, адаптированного под конкретный сервис. Для каждого из них.

В конце концов всё заканчивается множественными зависимостями, которые требуются для отправки и приёма HTTP сообщений. Если вы были в подобной ситуации, то вы, возможно, знаете, что множество разработчиков попадают в ловушку, которую я называю «принеси свой собственный HTTP клиент» («BYOHC» — “bring your own HTTP client”).

Свой HTTP-клиент

В последние годы группа PHP-FIG прилагает массу усилий для улучшения взаимодействий по протоколу HTTP. Первым результатом этих усилий стал стандарт PSR-7, который определяет интерфейс для HTTP сообщений. Он был принят в 2015 году и быстро получил поддержку и распространение во фреймворках и библиотеках во всей экосистеме PHP. Один шаг сделан — фреймворкам больше не нужно придумывать свои собственные, несовместимые абстракции HTTP.

В настоящее время практически любой HTTP пакет, который вы собираетесь использовать в своём приложении скорее всего опирается на интерфейс PSR-7. И здесь возникает камень преткновения: это всего лишь интерфейсы. Когда пакету необходимо выполнить HTTP запрос, то для этого ему нужно создать объект Request. Для работы с ним простого интерфейса недостаточно; и поэтому производитель решает пойти по легкому пути и использовать одну конкретную реализацию PSR-7.

У нас есть проблема: если вам необходимо два разных пакета, которые, в свою очередь, требуют две разные реализации PSR-7, то в итоге вы получаете две разные реализации PSR-7 в вашем приложении. Без необходимости.

И, к сожалению, на этом всё не заканчивается. Даже если бы у нас был способ создания HTTP сообщений в совместимом виде, пакет всё равно должен отправлять запрос и получать ответ через HTTP клиент. Опять же, производитель решает использовать свой любимый клиент, а другой производитель выбирает другой, и в итоге у вас в приложении два HTTP клиента. Без необходимости.

Но подождите, и это еще не конец! Более сознательный разработчик может подумать: «Я не хочу жесткую зависимость от одного HTTP-клиента», и он создает интерфейс (который обычно содержит один метод send(RequestInterface): ResponseInterface), с помощью которого предоставляет адаптер для выбранного им клиента, и на этом всё заканчивается.

В итоге вы получаете приложение, в котором вы можете выбрать один HTTP-клиент, который будет править всеми (ура!), но всё равно должны написать и поддерживать несколько адаптеров, которые, честно говоря, в основном будут отличаться только пространством имен интерфейса, в котором они реализованы. Или вы просто смиритесь с тем, что ваш каталог vendor загроможден тремя различными HTTP клиентами и двумя различными реализациями PSR-7.

Поговорим о стандартах

Конечно, это не оптимально, и, конечно, разработчики знают об этом. Потребовалось не так много времени, чтобы появилась группа разработчиков и начала работу над определением интерфейса для HTTP клиента. В то время эта деятельность называлась HTTPlug. Она быстро завоевала популярность. Параллельно они решили и другую проблему, определив интерфейсы для фабрик сообщений PSR-7, и реализовали механизм автоматического обнаружения для простоты использования.

Короче говоря, HTTPlug был предложен в PHP-FIG, и в результате у нас появилось ещё два удобных стандарта:

  • PSR-17 HTTP Factories, определяющий интерфейсы для PSR-7-совместимых фабрик, и
  • PSR-18 HTTP Клиент, определяющий интерфейсы для HTTP клиента и связанные с ним исключения.

В настоящее время следующие зависимости должны быть включены разработчиком при написании пакета, взаимодействующего по HTTP. Так они выглядят в файле composer.json:

{
  "require": {
    "psr/http-client": "^1.0",
    "psr/http-client-implementation": "*",
    "psr/http-factory": "^1.0",
    "psr/http-factory-implementation": "*"
  }
}

Пакеты psr/http-client и psr/http-factory предоставляют интерфейсы, а требование наличия двух соответствующих виртуальных пакетов (psr/http-client-implementation и psr/http-factory-implementation) гарантирует, что у пользователя вашего пакета установлена совместимая реализация.

Приведем пример использования этих интерфейсов:

use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;

final class MyServiceClient
{
	private RequestFactoryInterface $requestFactory;

	private StreamFactoryInterface $streamFactory;

	private ClientInterface $httpClient;

	public function __construct(
		RequestFactoryInterface $requestFactory,
		StreamFactoryInterface $streamFactory,
		ClientInterface $httpClient
	) {
		$this->requestFactory = $requestFactory;
		$this->streamFactory = $streamFactory;
		$this->httpClient = $httpClient;
	}

	public function addThing(array $thing): void
	{
		$body = $this->streamFactory->createStream(json_encode($thing));
		$request = $this->requestFactory->createRequest('POST', 'https://api.myservice.com/things')
			->withHeader('Content-Type', 'application/json')
			->withBody($body);

		$response = $this->httpClient->sendRequest($request);
		$statusCode = $response->getStatusCode();

		if ($statusCode > 500) {
			throw new ServiceUnavailableException();
		}

		if ($statusCode === 500) {
			throw new ServerErrorException();
		}

		if ($statusCode >= 400) {
			throw new ClientException();
		}
	}
}

Это очень схематичная реализация, но я считаю, что в ней заложен правильный посыл (хаха): вы делегируете создание объектов PSR-7 фабрикам PSR-17, и зависите от интерфейса PSR-18 для HTTP клиента. Этот код полагается на то, что ваше приложение предоставит нужные реализации с помощью инъекции зависимостей, и ему совершенно безразлично, что это за объекты, лишь бы они реализовывали требуемые интерфейсы.

Код также иллюстрирует один очень важный аспект спецификации PSR-18: HTTP клиент не выбрасывает исключение для кодов состояния не 2xx. Вы можете быть не в восторге от этого решения, но оно имеет определенный смысл, и, что самое важное, поведение теперь чётко определено. Помните сценарий выше, в котором вам нужно было написать адаптер для интерфейса каждого производителя? Не изучив код, вы не могли знать какое поведения HTTP-клиента они ожидают в подобных ситуациях, и поэтому легко могут возникнуть ошибки. Фух!

Что теперь?

Оба вышеупомянутых стандарта были приняты только в 2018 году, поэтому я полагаю, что поставщикам может потребоваться некоторое время для адаптации. Тем не менее, HTTPlug был довольно быстр в этом: они выпустили версию 2.0, которая унифицирует их клиентский интерфейс HTTP с PSR-18 и добавляет поддержку PSR-17 с помощью механизма автоматического обнаружения. Поэтому, если в пакете присутствует зависимость php-http/httplug:^2.0, то ваши фабрики PSR-17 и PSR-18-совместимый HTTP-клиент должны прекрасно с ним работать.

Если вы пишете пакет, который должен отправлять (синхронные) HTTP запросы и получать HTTP ответы, я не вижу причин против того, чтобы придерживаться исключительно PSR-17 и PSR-18. Так что, если вы пишете что-то с нуля, здесь нечего обсуждать, просто придерживайтесь PSR с самого начала. Если вы поддерживаете существующий код, пожалуйста, подумайте о рефакторинге, как только вы сможете позволить себе выпустить версию, ломающую обратную совместимость.

Это не значит, что время HTTPlug полностью прошло - хотя я думаю, что он выполнил свою задачу, проложив путь к тому, что в конечном итоге стало широко распространенным стандартом, он также определяет интерфейс для AsyncHttpClient. Это то, что еще не охвачено PSRs, в основном потому, что пока нет стандарта для промисов в PHP. Но это может измениться в будущем.

В качестве последнего замечания, я хотел бы выразить свою бесконечную благодарность людям, стоящим за HTTPlug, и тем, кто сделал возможным продвижение его в PHP-FIG.

Перевод статьи «Don’t bring your own HTTP client» by Jiří Pudil.