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

Передача данных клиентам, используя протокол 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. Изображение Docker, схема Helm для Kubernetes и управляемый Хаб высокой доступности (High Availability Hub) также предоставляются.

Если вы используете Symfony Docker или `Распределение платформы API`_, хаб Mercure автоматически устанавливается, и ваше приложение Symfony автоматически конфигурируется для его использования. Вы можете сразу перейти к следующему разделу.

Если вы используете Локальный веб-сервер Symfony, хаб Mercure будет автоматически доступен в качестве сервиса Docker, благодаря его :ref:`интеграции с Docker <symfony-server-docker>.

Убедитесь в том, что последние версии Docker и Docker Compose правильно установлены на вашем компьютере и запустите Локальный веб-сервер Symfony с опцией --no-tls:

1
$ symfony server:start --no-tls -d

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

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

1
$ composer require mercure

Symfony Flex автоматически установил и сконфигурировал MercureBundle. Он также создал (если это было необходимо) и сконфигурировал определение Docker Compose, которое предоставляет сервис Mercure. Выполните docker-compose up, чтобы запустить его.

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

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

Когда MercureBundle будет установлен, файл .env вашего проекта, будет обновлен рецептом Flex, чтобы включать в себя доступные переменные окружения.

Если вы используете Локальный веб-сервер Symfony, Symfony Docker или Распространение платформы API, приложение Symfony конфигурируется автоматически, и вы можете сразу перейти к следующему разделу.

В других случаях, установите URL вашего хаба как значения переменных окружения MERCURE_URL и MERCURE_PUBLIC_URL. Иногда приложению Symfony может понадобиться вызвать другой URL (обычно для публикации) и другого клиента JavaScript. Это особенно распространено, когда приложение Symfony должно использовать локальный URL, а код JavaScript клиентской стороны - публичный. В таком случае, MERCURE_URL должен содержать локальный URL, который будет использован приложением Symfony (например, https://mercure/.well-known/mercure), а MERCURE_PUBLIC_URL - публичнодоступным URL (например, https://example.com/.well-known/mercure).

Клиенты также должны иметь веб-токен 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, должны быть… секретными!

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

  • 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!',
                ],
            ],
        ],
    ]);
    

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

Публикация

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

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

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

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

class PublishController extends AbstractController
{
    public function publish(HubInterface $hub): Response
    {
        $update = new Update(
            'https://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 в шаблоне Twig является однозначной:

1
2
3
4
5
6
7
<script>
const eventSource = new EventSource("{{ mercure('https://example.com/books/1')|escape('js') }}");
eventSource.onmessage = event => {
    // Будет взыван каждый раз, когда сервер публикует обновление
    console.log(JSON.parse(event.data));
}
</script>

Функция Twig mercure() сгенерирует URL хаба Mercure в соответствии с конфигурацией. URL будет включать в себя параметры запроса topic, соответствующие темам, переданным в качестве первого аргумента.

Если вы хотите получить доступ к этому URL из внешнего файла JavaScript, сгенерируйте URL в соответствующему HTML-элементе:

1
2
3
<script type="application/json" id="mercure-url">
{{ mercure('https://example.com/books/1')|json_encode(constant('JSON_UNESCAPED_SLASHES') b-or constant('JSON_HEX_TAG'))|raw }}
</script>

Затем извлеките его из своего файла JS:

1
2
3
const url = JSON.parse(document.getElementById("mercure-url").textContent);
const eventSource = new EventSource(url);
// ...

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<script>
{# Подпишитесь на обновления нескольких источников Книг и на все источники Обзоров, совпадающие с заданным паттерном #}
const eventSource = new EventSource("{{ mercure([
    'https://example.com/books/1',
    'https://example.com/books/2',
    'https://example.com/reviews/{id}'
])|escape('js') }}");

eventSource.onmessage = event => {
    console.log(JSON.parse(event.data));
}
</script>

Tip

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

_images/chrome.png

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

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

Tip

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

Обнаружение

Протокол 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.

Куки могут быть установлены Symfony автоматически, путем передачи соответствующих опций функции Twig mercure(). Куки, установленные Symfony, будут автоматически переданы браузерами хабу Mercure, если атрибут withCredentials класса EventSource установлен как true. Затем, Хаб верифицирует валидность предоставленного JWT, и излечет из него селекторы тем.

1
2
3
4
5
<script>
const eventSource = new EventSource("{{ mercure('https://example.com/books/1', { subscribe: 'https://example.com/books/1' })|escape('js') }}", {
    withCredentials: true
});
</script>

Поддерживаемые опции:

  • subscribe: список селекторов тем для включения в заявление JWT mercure.subscribe
  • publish: список селекторов тем для включения в заявление JWT mercure.publish
  • additionalClaims: дополнительные заявления для включения в JWT (дата истечения срока годности, ID токена…)

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

Caution

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

Tip

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

1
2
3
4
5
6
7
<script>
const es = new EventSourcePolyfill("{{ mercure('https://example.com/books/1') }}", {
    headers: {
        'Authorization': 'Bearer ' + token,
    }
});
</script>

Программная установка куки

Иногда, может быть удобно установить куки авторизации из вашего кода, а не использовать функцию Twig. MercureBundle предоставляет удобный сервис, Authorization, чтобы сделать это.

В следующем примере контроллера, добавленный куки содержит JWT, который сам содержит соответствующий селектор темы.

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

// 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\Authorization;
use Symfony\Component\Mercure\Discovery;

class DiscoverController extends AbstractController
{
    public function publish(Request $request, Discovery $discovery, Authorization $authorization): JsonResponse
    {
        $discovery->addLink($request);
        $authorization->setCookie($request, ['https://example.com/books/1']);

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

Программное генерирование 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 string $name = '';

    #[ORM\Column]
    public string $status = '';
}

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

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

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

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

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

// tests/FunctionalTest.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('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';
    }

    // реализуйте остальные методы HubInterface здесь
}

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.

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

MercureBundle поставляется с панелью отладки. Установите пакет Debug, чтобы подключить ее:

.. code-block:: terminal
$ composer require –dev symfony/debug-pack
_images/panel.png

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

Tip

Асинхронный запуск не поощряется. Большинство хабов Mercure уже обрабатывают публикации асинхронно, и использование Messenger обычно не требуется.

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

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

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

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

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

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

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

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

Двигаемся дальше

  • Протокол Mercure также поддерживается компонентом Notifier. Используйте его для отправки пуш-уведомлений веб-браузерам.
  • Symfony UX Turbo - это библиотека, использующая Mercure для предоставления такого же опыта, как и с одностраничными приложениями, но без написания единой строчки JavaScript!

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