Дата обновления перевода 2021-09-28

Передача данных клиентам, используя протокол Mercure

Трансляция данных с серверов клиентам в реальном времени является требованием для множества совеременных веб и мобильных приложений.

Создание UI, реагирующего в режиме живого времени на изменения, сделанные другими пользователями (например, пользователь изменяет данные, которые в текущий момент просматривают несколько других пользователей, и все UI автоматически обновляются), уведомляющего пользователя, когда была выполнена асинхронная работа, или создание чат-приложений - наиболее распространенные случаи применения, требующие “пуш” возможностей.

Symfony предоставляет доступный компонент, который строится над протоколом Mercure, специально спроектированным для этого класса случаев применения.

Mercure - это открытый протокол, созданный с нуля, для публикации обновлений с сервера клиентам. Это современная и эффективная альтернатива поллингу, основанному на таймере, и WebSocket.

Так как он строится поверх Событий, отправленных сервером (SSE), Mercure поддерживается сразу после установки в большинстве современных браузеров (Edge и IE требуют polyfill), и имеет реализации на высоком уровне во многих языках программирования.

Mercure поставляется с механизмом авторизации, автоматическим переподключением в случае проблем сети с извлечением утерянных обновлений, наличием API, пуш-сообщения “без-соединения” для смартфонов и автоматическим обнаружением (поддерживаемый клиент может автоматически обнаружить и подписаться на обновления заданного ресурса, благодаря специфическому HTTP-заголовку).

Все эти функции поддерживаются в интеграции Symfony.

В отличие от WebSocket, который совместим только с HTTP 1.x, Mercure использует возможности мультиплексирования, предоставленные HTTP/2 и HTTP/3 (но также поддерживает и более старые версии HTTP).

В этой записи вы можете увидеть, как веб-API Symfony использует Mercure и платформу API, чтобы делать обновления в прямом эфире в приложении React и мобильном приложении (React Native), которые генерируются с использованием генератора клиентов платформы API.

Установка

Установка компонента Symfony

В приложениях, использующих Symfony Flex, запустите эту команду, чтобы установить поддержку Mercure, перед его использованием:

1
$ composer require mercure

Запуск хаба Mercure

Чтобы управлять устойчивыми соединениям, Mercure полагается на хаб: определенный сервер, который обрабатывает постоянные SSE-соединения с клиентами. Приложение Symfony публикует обновления в хабе, который будет транслировать их клиентам.

_images/schema.png

Официальная и общедоступная реализация (AGPL) хаба доступна для скачивания в виде статической бинарности с Mercure.rocks.

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

На Linux и Mac, выполните следующую команду для старта:

$ SERVER_NAME=:3000 MERCURE_PUBLISHER_JWT_KEY=’!ChangeMe!’ MERCURE_SUBSCRIBER_JWT_KEY=’!ChangeMe!’ ./mercure run -config Caddyfile.dev

На Windows, выполните:

Note

В качестве альтернатив бинарности существуют Docker image, Helm chart для Kubernetes и управляемые High Availability Hub также предоставляются Mercure.rocks.

Tip

Дистрибуция платформы API поставляется с конфигурацией Docker Compose, а также Helm chart для Kubernetes, которые на 100% совместимы с Symfony, и содержат Mercure hub. Вы можете скопировать их в ваш проект, даже если вы не используете платформу API.

Конфигурация

Предпочитаемый способ конфигурирования MercureBundle - с использованием переменных окружения.

Установите URL вашего хаба как значение переменной окружения MERCURE_PUBLISH_URL. Файл .env вашего проекта был обновлен рецептом Flex, чтобы предоставить примерные значения. Установите его как URL хаба Mercure Hub (http://localhost:3000/.well-known/mercure по умолчанию).

Кроме того, приложение Symfony должно иметь веб-токен JSON (JWT) к хабу Mercure, чтобы иметь авторизацию для публикации обновлений.

Этот JWT должен храниться в переменной окружения MERCURE_JWT_TOKEN.

JWT должен быть подписан тем же секретным ключом, который используется хабом для верификации JWT (!ChangeMe! в нашем примере). Его полезная нагрузка должна содержать как минимум следующую структуру, чтобы иметь разрешение на публикацию:

1
2
3
4
5
{
    "mercure": {
        "publish": []
    }
}

Так как массив пустой, приложение Symfony будет иметь право на публикацию только общедоступных обновлений (см. раздел авторизация для более детальной информации).

Tip

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

Caution

Не помещайте секретный ключ в MERCURE_JWT_TOKEN, он не будет работать! Эта переменная окружения должна содержать JWT, подписанный серкетным ключом.

Также, не забудьте, что и секретный ключ, и JWT, должны быть… секретными!

Базовое использование

Публикация

Компонент Mercure предоставляет объект значения Update, представляющий собой обновление для публикации. Он также предоставляет сервис Publisher для запуска обновлений в хабе.

Сервис Publisher может быть внедрен, используя автомонтирования в любой другой сервис, включая контроллеры:

// src/Controller/PublishController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;

class PublishController
{
    public function __invoke(HubInterface $hub): Response
    {
        $update = new Update(
            'http://example.com/books/1',
            json_encode(['status' => 'OutOfStock'])
        );

        $hub->publish($update);

        return new Response('published!');
    }
}

Первым параметром для передачи конструктору Update, является обновляемая тема. Эта тема должна быть IRI (Интернационализированный идентификатор ресурса, RFC 3987): уникальный идентификатор запускаемого ресурса.

Обычно, этот параметр содержит изначальный URL ресурса, переданного клиенту, но он может быть любой строкой или IRI, и не должен быть существующим URL (схоже с пространствами имен XML).

Второй параметр конструктора - содержание обновления. Это может быть что угодно, хранимое в любом формате. Однако, сериализация ресурса в формате гипермедиа, вроде JSON-LD, Atom, HTML или XML является рекомендуемой.

Подписки

Подписка на обновления JavaScript является однозначной:

1
2
3
4
5
const eventSource = new EventSource('http://localhost:3000/.well-known/mercure?topic=' + encodeURIComponent('http://example.com/books/1'));
eventSource.onmessage = event => {
    // Будет взыван каждый раз, когда сервер публикует обновление
    console.log(JSON.parse(event.data));
}

Mercure также позволяет подписываться на несколько тем, и использовать шаблоны URI Templates или спеицальное значение * (совпадающее со всеми темами), в качестве паттернов:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// URL - это встроенный класс JavaScript для манипуляции с URL
const url = new URL('http://localhost:3000/.well-known/mercure');
url.searchParams.append('topic', 'http://example.com/books/1');
// Подпишитесь на обновления нескольких ресурсов книг
url.searchParams.append('topic', 'http://example.com/books/2');
// Все ресурсы отзывов будут совпадать с этим паттерном
url.searchParams.append('topic', 'http://example.com/reviews/{id}');

const eventSource = new EventSource(url);
eventSource.onmessage = event => {
    console.log(JSON.parse(event.data));
}

Tip

Google Chrome DevTools нативно интегрирует практичный UI, вживую отображающий полученные события:

_images/chrome.png

Чтобы использовать его:

  • откройте DevTools
  • выберите вкладку “Network”
  • нажмите на запрос к хабу Mercure
  • нажмите на под-вкладку “EventStream”.

Tip

Протестируйте, совпадает ли шаблон URI с URL, используя `онлайн-отладчик`_

Асинхронный запуск

Вместо вызова сервиса Publisher напрямую, вы также можете позволить Symfony запускать обновления асинхронно, благодаря предоставленной интеграции с компонентом Messenger.

Для начала, убедитесь, что вы установили компонент Messenger и правильно сконфигурировали транспорт (если это не так, обработчик будет вызван синхронно).

Затем, запустите Update Mercure в автобус сообщений Messenger, и оно будет обработано автоматически:

// src/Controller/PublishController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Messenger\MessageBusInterface;

class PublishController
{
    public function __invoke(MessageBusInterface $bus): Response
    {
        $update = new Update(
            'http://example.com/books/1',
            json_encode(['status' => 'OutOfStock'])
        );

        // синхронно или асинхронно (RabbitMQ, Kafka...)
        $bus->dispatch($update);

        return new Response('published!');
    }
}

Обнаружение

Протокол Mercure имеет механизм обнаружения. Для его использования, приложение Symfony должно показать URL хаба Mercure в HTTP-заголовке Link.

_images/discovery.png

Вы можете создать заголовки Link с помощью компонента WebLink, используя метод помощника AbstractController::addLink:

// src/Controller/DiscoverController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mercure\Discovery;

class DiscoverController extends AbstractController
{
    public function __invoke(Request $request, Discovery $discovery): JsonResponse
    {
        // Link: <http://localhost:3000/.well-known/mercure>; rel="mercure"
        $discovery->addLink($request);

        return $this->json([
            '@id' => '/books/1',
            'availability' => 'https://schema.org/InStock',
        ]);
    }
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Извлеките изначальный источник, обслуживаемый веб-API Symfony
fetch('/books/1') // Has Link: <http://localhost:3000/.well-known/mercure>; rel="mercure"
    .then(response => {
        // Извлеките URL хаба из заголовка Link
        const hubUrl = response.headers.get('Link').match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1];

        // Добавьте в начало тему(ы) для подписки в качестве параметра запроса
        const hub = new URL(hubUrl);
        hub.searchParams.append('topic', 'http://example.com/books/{id}');

        // Подпишитесь на обновления
        const eventSource = new EventSource(hub);
        eventSource.onmessage = event => console.log(event.data);
    });

Авторизация

Mercure также позволяет запускать обновления только для авторизованных клиентов. Чтобы сделать это, отметьте обновление как приватное, установив третий параметр конструктора Update как true:

// src/Controller/Publish.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Update;

class PublishController extends AbstractController
{
    public function __invoke(HubInterface $hub): Response
    {
        $update = new Update(
            'http://example.com/books/1',
            json_encode(['status' => 'OutOfStock']),
            true // private
        );

        // JWT издателя должен содержать эту тему, совпадающий с ней шаблон URI или * в mercure.publish, иначе вы получите ошибку 401
        // JWT подписчика должен содержать эту тему, совпадающий с ней шаблон URI или * в mercure.subscribe, чтобы получить обновление
        $hub->publish($update);

        return new Response('private update published!');
    }
}

Для того, чтобы подписаться на приватные обновления, подписчики должны предоставить хабу JWT, содержащий выборщик темы, совпадающий с темой обновления.

Чтобы предоставить этот JWT, подписчик может использовать куки, или HTTP-заголовок Authorization.

Куки отправляются браузерами автоматически, при открытии соединения EventSource, если атрибут withCredentials установлен, как true:

1
2
3
const eventSource = new EventSource(hub, {
    withCredentials: true
});

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

Tip

Нативная реализация EventSource не позволяет указывать заголовки. Например, авторизацию с использованием токена Bearer. Чтобы достичь этого, используйте polyfill

1
2
3
4
5
const es = new EventSourcePolyfill(url, {
    headers: {
        'Authorization': 'Bearer ' + token,
    }
});

В следующем примере контроллера, сгенерированный куки содержит JWT, который, в свою очередь, содержит соответствующий выборщик темы. Этот куки будет автоматически отправлен веб-браузером при подсоединении к хабу. Затем, хаб верифицирует валидность предоставленного JWT, и извлечет из него выборщики тем.

Добавьте ваш секрет JWT к конфигурации следующим образом:

  • YAML
    1
    2
    3
    4
    5
    6
    7
    # config/packages/mercure.yaml
    mercure:
        hubs:
            default:
                url: https://mercure-hub.example.com/.well-known/mercure
                jwt:
                    secret: '!ChangeMe!'
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    <!-- config/packages/mercure.xml -->
    <?xml version="1.0" encoding="UTF-8" ?>
    <config>
        <hub
            name="default"
            url="https://mercure-hub.example.com/.well-known/mercure"
        >
            <jwt secret="!ChangeMe!"/>
        </hub>
    </config>
    
  • PHP
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    // config/packages/mercure.php
    $container->loadFromExtension('mercure', [
        'hubs' => [
            'default' => [
                'url' => 'https://mercure-hub.example.com/.well-known/mercure',
                'jwt' => [
                    'secret' => '!ChangeMe!',
                ]
            ],
        ],
    ]);
    

А вот и контроллер:

// src/Controller/DiscoverController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\Authorization;
use Symfony\Component\Mercure\Discovery;

class DiscoverController extends AbstractController
{
    public function __invoke(Request $request, Discovery $discovery, Authorization $authorization): Response
    {
        $discovery->addLink($request);

        $response = new JsonResponse([
            '@id' => '/demo/books/1',
            'availability' => 'https://schema.org/InStock'
        ]);

        $response->headers->setCookie(
            $authorization->createCookie($request,  ["http://example.com/books/1"])
        );

        return $response;
    }
}

Caution

Чтобы использовать метод аутентификации куки, приложение Symfony и хаб должны быть обслужены с одного домена (могут быть разные суб-домены).

Программное генерирование JWT, используемых для публикации

Вместо хранения JWT напрямую в конфигурации, вы можете создать поставщик токенов, который будет возвращать токен, используемый объектом HubInterface:

// src/Mercure/MyTokenProvider.php
namespace App\Mercure;

use Symfony\Component\Mercure\JWT\TokenProviderInterface;

final class MyTokenProvider implements TokenProviderInterface
{
    public function getToken(): string
    {
        return 'the-JWT';
    }
}

Затем, сошлитесь на этот сервис в конфигурации пакета:

  • YAML
    1
    2
    3
    4
    5
    6
    7
    # config/packages/mercure.yaml
    mercure:
        hubs:
            default:
                url: https://mercure-hub.example.com/.well-known/mercure
                jwt:
                    provider: App\Mercure\MyTokenProvider
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    <!-- config/packages/mercure.xml -->
    <?xml version="1.0" encoding="UTF-8" ?>
    <config>
        <hub
            name="default"
            url="https://mercure-hub.example.com/.well-known/mercure"
        >
            <jwt provider="App\Mercure\MyTokenProvider"/>
        </hub>
    </config>
    
  • PHP
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    // config/packages/mercure.php
    use App\Mercure\MyJwtProvider;
    
    $container->loadFromExtension('mercure', [
        'hubs' => [
            'default' => [
                'url' => 'https://mercure-hub.example.com/.well-known/mercure',
                'jwt' => [
                    'provider' => MyJwtProvider::class,
                ]
            ],
        ],
    ]);
    

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

Веб-API

При создании веб-API, удобно, когда есть возможность незамедлительно отправлять новые версии ресурсов всем подсоединенным устройствам, и обновлять их просмотр.

Платформа API может использовать компонент Mercure для автоматического запуска обновлений, каждый раз при создании, изменении или удалении API-ресурса.

Начните с установки библиотеки, используя ее официальный рецепт:

1
$ composer require api

Затем, создания следующей сущности достаточно для того, чтобы получить полноценный гипермедиа API и автоматическую трансляцию обновлений через хаб Mercure:

// src/Entity/Book.php
namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;

/**
* @ApiResource(mercure=true)
* @ORM\Entity
*/
class Book
{
    /**
     * @ORM\Id
     * @ORM\Column
     */
    public $name;

    /**
     * @ORM\Column
     */
    public $status;
}

Как показано в этой записи, генератор клиентов платформы API также позволяет автоматически генерировать код полных приложений React и React Native из этого API. Эти приложения будут отображать содержания обновлений Mercure в режиме реального времени.

Прочтите документацию платформы API, чтобы узнать больше о ее поддержке Mercure.

Тестирование

Во время модульного тестирования обновления Mercure отправлять не надо.

Вместо этого вы можете использовать MockHub:

// tests/Functional/.php
namespace App\Tests\Unit\Controller;

use App\Controller\MessageController;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\JWT\StaticTokenProvider;
use Symfony\Component\Mercure\MockHub;
use Symfony\Component\Mercure\Update;

class MessageControllerTest extends TestCase
{
    public function testPublishing()
    {
        $hub = new MockHub('default', 'https://internal/.well-known/mercure', new StaticTokenProvider('foo'), function(Update $update): string {
            // $this->assertTrue($update->isPrivate());

            return 'id';
        });

        $controller = new MessageController($hub);

        // ...
    }
}

Во время функционального тестирования вы можете декорировать хаб:

// tests/Functional/Fixtures/HubStub.php
namespace App\Tests\Functional\Fixtures;

use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;

class HubStub implements HubInterface
{
    public function publish(Update $update): string
    {
        return 'id';
    }

    // implement rest of HubInterface methods here
}

HubStub декорирует сервис хаба по умолчанию, поэтому никакие обновления на самом деле не отправляются. Вот реализация HubStub:

1
2
3
# config/services_test.yaml
App\Tests\Functional\Fixtures\HubStub:
    decorates: mercure.hub.default

Отладка

New in version 0.2: Панель WebProfiler была представлена в MercureBundle 0.2.

Подключите панель в вашей конфигурации следующим образом:

  • YAML
    1
    2
    3
    # config/packages/mercure.yaml
    mercure:
        enable_profiler: '%kernel.debug%'
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    <!-- config/packages/mercure.xml -->
    <?xml version="1.0" encoding="UTF-8" ?>
    <container xmlns="http://symfony.com/schema/dic/services"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://symfony.com/schema/dic/services
            https://symfony.com/schema/dic/services/services-1.0.xsd">
    
        <mercure:config enable_profiler="%kernel.debug%"/>
    
    </container>
    
  • PHP
    1
    2
    3
    4
    // config/packages/mercure.php
    $container->loadFromExtension('mercure', [
        'enable_profiler' => '%kernel.debug%',
    ]);
    
_images/panel.png

Эта документация является переводом официальной документации Symfony и предоставляется по свободной лицензии CC BY-SA 3.0.