PSR-7 в примерах

Стандарт PSR-7 успешно завершён. На этой неделе были добавлены последние штрихи. И теперь версия 0.6.0 пакета http-message package готова к использованию. Попробуйте следовать этому стандарту в своих приложениях.
Я до сих пор слышу замечания как по поводу слишком упрощённого, так и по поводу слишком сложного изложения. Именно поэтому написан этот пост — чтобы продемонстрировать использование опубликованных рекомендаций и показать одновременно и их простоту, и полноту и надёжность, которые они предоставляют.

Для начала, я кратко расскажу о том, что регулирует этот стандарт.

HTTP сообщения

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

<message line>
Header: value
Another-Header: value
Message body

Заголовки представляют собой пары ключ–значение. Ключи являются чувствительными к регистру. Значения представляют собой строки. Один заголовок может иметь несколько значений. В этом случае обычно значения представлены списком, разделённым запятыми.
Тело сообщения (Message body) – это строка. Хотя обычно оно обрабатывается сервером и клиентом как поток, чтобы уменьшить потребление памяти и снизить нагрузку при обработке. Это чрезвычайно важно, когда передаются большие наборы данных, и особенно, когда передаются файлы. Например, PHP «из коробки» представляет входящее тело запроса как поток php://input и использует выходной буфер (тоже, формально, поток), чтобы вернуть ответ.
Строка сообщения (message line) – это место, которое отличает HTTP запрос от ответа.
Строка сообщения у запроса (далее строка запроса) имеет следующий формат:

METHOD request-target HTTP/VERSION

Где метод (“METHOD”) определяет вид запроса: GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD и так далее, версия протокола (“VERSION”) обычно 1.0 или 1.1 (чаще 1.1 у современных веб клиентов). А на цели запроса (“request-target”) остановимся подробнее.
Цель запроса может быть представлена следующим образом:

  • В стандартной форме, которая представляет собой путь и строку запроса (если представлена), т.е. URI (Universal Resource Identifier).
  • В абсолютной форме, которая представляет собой абсолютный URI .
  • В форме для авторизации, которая представляет собой часть URI необходимую для авторизации (информация о пользователе, если представлена; хост; и порт, если не стандартный).
  • В виде *, то есть строка, состоящая из символа «*».

Обычно, данные для авторизации HTTP клиент передаёт только с первым запросом, чтобы соединиться с HTTP сервером. Потом в качестве цели запроса отправляется просто относительный (или абсолютный) путь к ресурсу (URI без данных для авторизации). Таким образом данные для авторизации передаются только для запроса на соединение (метод CONNECT ), который обычно выполняется, когда идет работа с прокси сервером. Символ «*» используется с методом OPTIONS, чтобы получить общие сведения о веб сервере.
Короче говоря, есть много вариантов использования цели запроса.
Теперь, чтобы окончательно вас запутать, рассмотрим URI. Мы видим следующее:

<scheme>://<authority>[/<path>][?<query string>]

“scheme” в http запросе будет или http, или https. “path” – это тоже всем понятная часть. Но что такое “authority”?

[user-info@]host[:port]

“authority” всегда содержит хост, который может быть доменным именем или IP адресом. Порт является опциональным и необходим только в том случае, когда не является стандартным для данной схемы (или если схема неизвестна). Информация о пользователе представляется в виде

user[:pass]

где пароль является необязательным. Фактически, в существующих спецификациях, рекомендуется вообще не использовать пароль в URI. Лучше принудительно запросить пароль у клиента.
Строка запроса – это набор пар ключ-значение, разделённых амперсандами:

?foo=bar&baz&quz=1

В зависимости от языка реализации, она так же может моделировать списки или массивы:

?sort[]=ASC&sort[]=date&filter[product]=name

PHP преобразует данную строку в двумерный массив:

[
    'sort' => [
        'ASC',
        'date'
    ],
    'filter' => [
        'product' => 'name'
    ],
]

Итак, если бы гибкости в формировании цели запроса нам оказалось бы недостаточно, URI предоставил бы свою.
К счастью, ответы HTTP сервера проще. Строка ответа выглядит следующим образом:

HTTP/VERSION <status>[ <reason>]

“VERSION”, как говорилось ранее, — это обычно 1.0 или, чаще, 1.1. “status” – это число от 100 до 599 включительно; «reason» — пояснение к статусу, стандартное для каждого статуса.
Итак, это был беглый обзор HTTP сообщений. Давайте теперь посмотрим, как PSR-7 моделирует их.

Заголовки сообщений

Названия заголовков сообщений изначально регистронезависимы. К сожалению, большинство языков и библиотек приводят их к одному регистру. Как пример, PHP хранит их в массиве $_SERVER в верхнем регистре, с префиксом HTTP_, и подставляя _for – (это для соответствия с Common Gateway Interface (CGI) спецификацией).
PSR-7 упрощает доступ к заголовкам, предоставляя объектно-ориентированный слой над ними

// Вернёт null, если не найдено:
$header = $message->getHeader('Accept');
// Проверить, указан ли заголовок:
if (! $message->hasHeader('Accept')) {
}
// Если у заголовка несколько значений,
// то вернётся массив:
$values = $message->getHeaderLines('X-Foo');

Вся вышеуказанная логика не зависит от того, как указан заголовок; accept, ACCEEPT или даже aCCePt будут корректными именами заголовка и вернут одинаковый результат.
PSR-7 предполагает, что разбор всех заголовков вернёт структуру в виде массива:

/* Returns the following structure:
    [
        'Header' => [
            'value1'
            'value2'
        ]
    ]
 */
foreach ($message->getAllHeaders() as $header => $values) {
}

Когда структура определена, пользователи точно будут знать, что получат и могут обрабатывать заголовки удобным им способом – вне зависимости от реализации.
Но что если вы захотите добавить заголовки к сообщению – например, чтобы создать запрос и передать его HTTP клиенту?
Сообщения в PSR-7 смоделированы в виде объектов-значений; это означает, что любое изменение в состоянии – это, фактически, другое значение. Таким образом, определение нового заголовка создаст в результате новый объект сообщения.

$new = $message->withHeader('Location', 'http://example.com');

Если вам нужно просто обновить значение, вы можете просто переопределить его:

$message = $message->withHeader('Location', 'http://example.com');

Если вы хотите добавить другое значение к уже существующему заголовку, вы можете поступить следующим образом:

$message = $message->withAddedHeader('X-Foo', 'bar');

Или даже удалить заголовок:

$message = $message->withoutHeader('X-Foo');

Тело сообщения

Как было указано выше, тело сообщения обычно обрабатывается как поток для улучшения производительности. Это особенно важно, когда вы передаёте файлы, используя HTTP. Если только вы не собираетесь использовать всю доступную память на текущий процесс. Большинство реализаций HTTP сообщений, которые я просмотрел, забывают об этом или пытаются изменить поведение по факту (да, даже ZF2 этим грешит!). Если вы хотите подробнее прочитать о преимуществах данного подхода, прочитайте статью Майкла Доулинга. Он написал в своём блоге об использовании потоков в PSR-7 прошлым летом.
Итак, тело сообщения в PSR-7 смоделировано как поток.
«Но это слишком сложно для 80% случаев, где можно обойтись строками!» — наиболее частый аргумент в числе тех, что критикуют данную реализацию обработки тела сообщения. Хорошо, давайте рассмотрим следующее:

$body = new Stream('php://temp');
$body->write('Here is the content for my message!');

Данный пример, и все последующие примеры работы с HTTP сообщениями в данном посте будут использовать phly/http – библиотеку, написанную мной и отражающую развитие PSR-7. В данном случае Stream реализует StreamableInterface.
По существу, вы получаете тонкий, объектно-ориентированный интерфейс для взаимодействия с телом сообщения, который позволяет добавить к нему информацию, прочитать её и многое другое. Хотите изменить сообщение? Создайте новое тело сообщения:

$message = $message->withBody(new Stream('php://temp'));

Моё мнение состоит в том, что несмотря на то, что представление тела сообщения в виде потока кажется сложным, на деле реализация и использование достаточно просты и понятны.
Выгода использования StreamableInterface в PSR-7 состоит в том, что он предоставляет гибкость, упрощающую реализацию различных шаблонов проектирования. Например, вы можете реализовать “callback” функцию, которая при вызове метода read() или getContents() возвращает содержание сообщения (Drupal, в частности, использует этот шаблон). Или «Iterator», реализация которого использует любой «Traversable», чтобы вернуть или объединить контент. Смысл в том, что такой интерфейс даёт вам широкий простор реализации множества шаблонов для работы с телом сообщения. А не ограничивает просто строками или файлами.
StreamableInterface предлагает набор методов, которые чаще всего используются при работе с телом HTTP сообщения. Это не означает, что он предусматривает абсолютно всё, но покрывает большой набор потенциально необходимых операций.
Лично я люблю использовать потоки php://temp, потому как они находятся в памяти до тех пор, пока не станут достаточно большими (в этом случае они записываются во временный файл на диске). Метод может быть достаточно эффективным.

Ответы

До сих пор мы рассматривали функции, общие для любых сообщений. Теперь я собираюсь остановиться на ответах в частности.
У ответа есть статус код и пояснительная фраза:

$status = $response->getStatusCode();
$reason = $response->getReasonPhrase();

Это легко запомнить. Теперь, что если мы сами формируем ответ?
Пояснительная фраза считается необязательной (но в тоже время стандартной для каждого статус кода). Для неё интерфейс предусматривает специфичный для ответа мутатор withStatus():

$response = $response->withStatus(418, "I’m a teapot");

Повторюсь, сообщения смоделированы как объекты-значения; изменение любого значения создаст в результате новый экземпляр, который должен быть привязан к ответу или запросу. Но в большинстве случаев вы будете просто переназначать текущий экземпляр.

Запросы

Запросы содержат следующее:

  • Метод.
  • URI / цель запроса.

Последний немного сложен для моделирования. Вероятно в 99% случаев, мы увидим в качестве цели запроса стандартный URI. Но это не отменяет того факта, что необходимо предусмотреть и другие типы целей запроса. Таким образом, интерфейс запроса выполняет следующее:

  • Составляет экземпляр UriInterface, который моделирует URI запроса.
  • Предоставляет два метода для цели запроса: getRequestTarget(), который возвращает цель запроса, и вычисляет её, если не представлено (используя предлагаемый URI, чтобы вернуть исходную форму или чтобы вернуть «/», если URI не предоставлен или не содержит путь); и withRequestTarget(), чтобы создавать новый экземпляр со специфической целью запроса.

Это в дальнейшем позволит нам адресовать запросы с произвольной целью запроса, когда это необходимо (например, используя информацию об URI в запросе для установки соединения с HTTP клиентом).
Давайте получим метод и URI с запроса:

$method = $request->getMethod();
$uri    = $request->getUri();

$uri в данном случае будет экземпляром UriInterface, и позволит вам использовать URI:

// части URI:
$scheme    = $uri->getScheme();
$userInfo  = $uri->getUserInfo();
$host      = $uri->getHost();
$port      = $uri->getPort();
$path      = $uri->getPath();
$query     = $uri->getQuery();     // строка запроса
$authority = $uri->getAuthority(); // [user-info@]host[:port]

Точно так же, как и HTTP сообщения, URI представлены в виде объектов-значений, и изменение любой части URI меняет его значение, мутирующие методы возвращают новый экземпляр:

$uri = $uri
    ->withScheme('http')
    ->withHost('example.com')
    ->withPath('/foo/bar')
    ->withQuery('?baz=bat');

Так как изменение URI означает создание нового экземпляра, то если вы хотите, чтобы изменение отразились в вашем запросе, вам необходимо сообщить об этих изменениях объекту запроса; и, как и с любым сообщением, если необходимо изменить метод или URI в конкретном экземпляре, следует использовать следующие методы:

$request = $request
    ->withMethod('POST')
    ->withUri($uri->withPath('/api/user'));

Серверные запросы

Серверные запросы имеют немного другие задачи, нежели стандартные сообщения HTTP запросов. Родной для PHP Server API (SAPI) предоставляет нам набор обычных, как для PHP разработчиков, функций:

  • Десериализация аргументов в строке запроса ($_GET).
  • Десериализация закодированных данных переданных методом POST ($_POST).
  • Десериализация куки ($_COOKIE).
  • Индикация и обработка загруженных файлов ($_FILES).
  • Инкапсуляция параметров CGI/SAPI ($_SERVER).

Аргументы из строки запроса, данные из тела запроса и куки могут быть получены с разных частей запроса, но было бы удобно, чтобы это было реализовано за нас. Бывают случаи, когда нам может потребоваться работать с этими значениями:

  • Для API, данные могут быть в формате XML или JSON и могут быть переданы не только методом POST. То есть мы должны расшифровать данные и потом снова внедрить их в запрос.
  • Многие фреймворки сейчас шифруют куки, и это означает, что их нужно расшифровать и внедрить в запрос.

Итак, PSR-7 предоставляет специальный интерфейс, ServerRequestInterface, который расширяет базовый RequestInterface, описывающий функции работы с подобными данными:

$query   = $request->getQueryParams();
$body    = $request->getBodyParams();
$cookies = $request->getCookieParams();
$files   = $request->getFileParams();
$server  = $request->getServerParams();

Представим, что вы пишете API и хотите принимать запросы в формате JSON; выполнение этого может выглядеть следующим образом:

$accept = $request->getHeader('Accept');
if (! $accept || ! preg_match('#^application/([^+\s]+\+)?json#', $accept)) {
    $response->getBody()->write(json_encode([
        'status' => 405,
        'detail' => 'This API can only provide JSON representations',
    ]));
    emit($response
        ->withStatus(405, 'Not Acceptable')
        ->withHeader('Content-Type', 'application/problem+json')
    );
    exit();
}
$body = (string) $request->getBody();
$request = $request
    ->withBodyParams(json_decode($body));

Пример выше демонстрирует несколько функций. Во-первых, он показывает извлечение заголовка из запроса, и ветвление логики, основанное на этом заголовке. Во-вторых, он показывает формирование объекта запроса в случае ошибки (функция emit() является гипотетической, она принимает объект запроса и отдаёт заголовки и тело запроса). Наконец, пример демонстрирует получение тела запроса, десериализацию и внедрение его снова в запрос.

Атрибуты

Другая особенность серверных запросов – это атрибуты. Они предназначены для хранения значений, которые получены из текущего запроса. Частый пример использования – это хранение результатов маршрутизации (разделение URI на пары ключ/значение).
Работа с атрибутами состоит из следующих методов:

  • getAttribute($name, $default = null) для получения определённого атрибута и возврата значения по умолчанию, если атрибут не найден.
  • getAttributes() получение всех атрибутов.
  • withAttribute($name, $value) для возврата нового экземпляра ServerRequestInterface, который содержит данный атрибут.
  • withoutAttribute(($name) для возврата экземпляра ServerRequestInterface без указанного атрибута.

В качестве примера давайте рассмотрим Aura Router с нашим экземпляром запроса:

use Aura\Router\Generator;
use Aura\Router\RouteCollection;
use Aura\Router\RouteFactory;
use Aura\Router\Router;
$router = new Router(
    new RouteCollection(new RouteFactory()),
    new Generator()
);
$path  = $request->getUri()->getPath();
$route = $router->match($path, $request->getServerParams());
foreach ($route->params as $param => $value) {
    $request = $request->withAttribute($param, $value);
}

Экземпляр запроса в данном случае используется для упорядочивания данных и передачи маршруту. Затем результаты маршрутизации используются для создания экземпляра ответа.

Варианты использования

Теперь после быстрой экскурсии по различным компонентам PSR-7, давайте вернёмся к конкретным примерам использования.

Клиенты

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

$response = $client->send($request);

Благодаря тому, что сообщения и URI смоделированы как объекты-значения, это так же означает, что разработчики могут создавать базовые экземпляры запросов и URI и создавать раздельные запросы и URI из них:

$baseUri     = new Uri('https://api.example.com');
$baseRequest = (new Request())
    ->withUri($baseUri)
    ->withHeader('Authorization', $apiToken);
while ($action = $queue->dequeue()) {
    // Новый объект запроса! Содержит только
    // URI и заголовок с авторизацией с базового.
    $request = $baseRequest
        ->withMethod($action->method)
        ->withUri($baseUri->withPath($action->path)); // новый URI!
    foreach ($action->headers as $header => $value) {
        // Базовый запрос НЕ получит данные заголовки, что обеспечит последующим
        // запросам содержать только необходимые заголовки!
        $request = $request->withHeader($header, $value);
    }
    $response = $client->send($request);
    $status   = $response->getStatusCode();
    if (! in_array($status, range(200, 204))) {
        // Запрос провален!
        break;
    }
    // Получить данные!
    $data->enqueue(json_decode((string) $response->getBody()));
}

Что PSR-7 предлагает, так это стандартный способ взаимодействия с запросами, которые вы отправляете клиентом, и ответами, которые получаете. Реализуя объекты-значения, мы открываем возможность некоторым интересным вариантам использования с прицелом на упрощение шаблона «сброс запроса» — изменение запроса всегда даёт в результате новый экземпляр, позволяя нам иметь базовый экземпляр с известным состоянием, который мы всегда можем расширить.

Связующее звено

Я не буду долго на этом останавливаться, т.к. уже делал это в статье. Основная идея, если кратко, состоит в следующем:

function (
    ServerRequestInterface $request,
    ResponseInterface $response,
    callable $next = null
) {
}

Функция принимает два HTTP сообщения, и выполняет некоторые преобразования с ними (которые могут включать делегирование к следующему доступному связующему звену). Обычно эти звенья возвращают объект ответа.
Другой вариант, который часто используется – это лямбда выражения (спасибо Ларри Гарфилду, который выслал мне на почту этот термин!):

/* response = */ function (ServerRequestInterface $request) {
    /* ... */
    return $response;
}

В лямбда звене вы составляете одно в другом:

$inner = function (ServerRequestInterface $request) {
    /* ... */
    return $response;
};
$outer = function (ServerRequestInterface $request) use ($inner) {
    /* ... */
    $response = $inner($request);
    /* ... */
    return $response;
};
$response = $outer($request);

И наконец, есть метод, продвигаемый Rack и WSGI, в котором каждое звено является объектом и проходит к выходу:

class Command
{
    private $wrapped;
    public function __construct(callable $wrapped)
    {
        $this->wrapped = $wrapped;
    }
    public function __invoke(
        ServerRequestInterface $request,
        ResponseInterface $response
    ) {
        //возможное управление запросом
        $new = $request->withAttribute('foo', 'bar');
        // делегирование звену, которое мы определили:
        $result = ($this->wrapped)($new, $response);
        // смотрим, получился ли в результате ответ
        if ($result instanceof ResponseInterface) {
            $response = $result;
        }
        // управление ответом перед его возвращением
        return $reponse->withHeader('X-Foo', 'Bar');
    }
}

Использование промежуточного звена состоит в том, что оно реализует связь между запросом и ответом и следует стандарту: предсказуемый шаблон с предсказуемым поведением. Это отличный метод написания веб компонентов, которые могут быть переиспользованы.

Фреймворки

Одна вещь, которую фреймворки предлагают на протяжении многих лет – это … слой абстракции над HTTP сообщениями. Цель PSR-7 предоставить общий набор интерфейсов для фреймворков, чтобы последние могли использовать одинаковые абстракции. Это позволит разработчикам писать переиспользуемый, независимый от фреймворка код или, по крайней мере, это то, что я хотел бы увидеть!
Рассмотрим Zend Framework 2. Он определяет интерфейс Zend\Stdlib\DispatchableInterface, который является базовым для любого контроллера, который вы собираетесь использовать в фреймворке:

use Zend\Http\RequestInterface;
use Zend\Http\ResponseInterface;
interface DispatchableInterface
{
    public function dispatch(
        RequestInterface $request,
        ResponseInterface $response
    );
}

Это как раз описанное нами выше промежуточное звено; одно единственное различие состоит в том, что оно использует специфичные для данного фреймворка реализации HTTP сообщений. Что если вместо этого оно будет поддерживать PSR-7?
Большинство реализаций HTTP сообщений в фреймворках построено таким образом, что вы можете изменить состояние сообщения в любое время. Иногда это может быть не совсем верно, особенно если допустить, что состояние сообщения может быть уже недействительно. Но это, пожалуй, единственный недостаток данного метода.
Сообщения по стандарту PSR-7 являются объектами-значениями. Таким образом, вам не потребуется сообщать приложению каким-либо образом о любом изменении в сообщениях. Это делает реализацию более явной и простой для отслеживания в вашем коде (и пошагово в отладчике, и с использованием статических анализаторов кода).
В качестве примера, если ZF2 будет обновлён в соответствии с PSR-7, разработчики не будут обязаны сообщать MvcEvent о любых изменениях, которые они хотят передать сдедующим клиентам:

// Внутри контроллера
$request  = $request->withAttribute('foo', 'bar');
$response = $response->withHeader('X-Foo', 'bar');
$event = $this->getEvent();
$event->setRequest($request)
      ->setResponse($response);

Приведённый выше код ясно показывает, что мы меняем состояние приложения.
Использование объектов-значений делает более простой одну особенную практику: распределение подзапросов или реализация Hierarchical MVC (HMVC). В этом случае вы можете создавать новые запросы, основанные на текущем, без информирования об этом приложения, и будучи уверенными, что состояние приложения не изменится.
В общем, для большинства фреймворков, использование PSR-7 сообщений переведёт к переносимой абстракции над HTTP сообщениями. Это даст возможность реализовать универсальное промежуточное звено. Адаптация сообщений, однако, потребует незначительных изменений. Разработчикам необходимо обновить код, отвечающий за отслеживание состояния приложения.

Источники

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

Я также рекомендую вам прочитать «пояснительную записку», так как она описывает идеи, разработанные решения и результаты (бесконечных) споров на протяжении двух лет:

Последние обновления опубликованы в пакете psr/http-message, который вы можете установить через composer. Это всегда самые последние обновлённые предложения.
Я создал библиотеку, phly/http, которая предлагает конкретную реализацию предложенных интерфейсов. Её так же можно установить через composer.
Наконец, если вы хотите поэкспериментировать с промежуточным звеном, основанным на PSR-7, предлагаю следующие варианты:

  • phly/conduit, портированная с Sencha библиотека Connect, изпользующая phly/http и psr/http-message в своей основе.
  • Stacker, StackPHP-подобная реализация, написанная Ларри Гарфилдом.

Я вижу будущее разработки с PSR-7. И верю, что он породит совершенно новое поколение PHP приложений.