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

Дата обновления перевода 2023-01-16

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

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

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

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

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

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

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

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

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

Установка

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

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

1
$ composer require mercure

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

Благодаря интеграции Docker с Symfony, Flex предлагает установить хаб Mercure. Выполните docker-compose up, чтобы запустить хаб, если вы выбрали эту опцию.

Если вы используете локальный веб-сервер Symfony, вы должны начать его с опции --no-tls.

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

Запуск хаба Mercure

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

В других случаях, и в прозиводстве, вам нужно установить хаб самостоятельно. Хаб официального и открытого источника (AGPL), основанный на веб-сервере Caddy, можно скачать как статичную бинарность с Mercure.rocks. Изображение Docker, схема Helm для Kubernetes и управляемый Хаб высокой
доступности, также предоставляются.

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

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

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

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

В других случаях, установите 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 (!ChangeThisMercureHubJWTSecretKey!, если вы используете интеграцию Docker). Этот секретный ключ должен храниться в переменной окружения MERCURE_JWT_SECRET. MercureBundle будеи использовать его, чтобы автоматически сгенерировать и подписать необходимые JWT.

В дополнение к этим переменным окружения, MercureBundle предоставляет более продвинутую конфигурацию:

  • secret: Ключ, используемый для подписания JWT - Ключ того же размера, что и хешированный вывод (например, 256 битов для "HS256") или больше ДОЛЖНО быть использовано. (Все другие опции, кроме algorithm, subscribe, и publish, будут проигнорированы)
  • publish: Список тем, разрешённых к публикации, при генерировании JWT (используется только когда предоставлены secret или factory)
  • subscribe: Список тем, на которые можно подписываться при генерировании JWT (используется только когда предоставлены secret или factory)
  • algorithm: Алгоритм, используемые для подписания JWT (используется только когда предоставлен secret)
  • provider: ID сервиса, который надо вызвать, чтобы предоставить JWT (все другие опции будут проигнорированы)
  • factory: ID сервиса, который надо вызвать, чтобы создать JWT (все другие опции, кроме subscribe и publish, будут проигнорированы)
  • value: Сырой JWT для использования (все другие опции будут проигнорированы)
  • YAML
  • XML
  • PHP
1
2
3
4
5
6
7
8
9
10
11
12
13
# config/packages/mercure.yaml
mercure:
    hubs:
        default:
            url: https://mercure-hub.example.com/.well-known/mercure
            jwt:
                secret: '!ChangeThisMercureHubJWTSecretKey!'
                publish: ['foo', 'https://example.com/foo']
                subscribe: ['bar', 'https://example.com/bar']
                algorithm: 'hmac.sha256'
                provider: 'My\Provider'
                factory: 'My\Factory'
                value: 'my.jwt'

Tip

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

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

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

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

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

Публикация

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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, отображающий полученные события в реальном времени:

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

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

Tip

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

Обнаружение

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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, который сам содержит соответствующий селектор темы.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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'
        ]);
    }
}

Tip

Вы не можете использовать помощник mercure() и метод setCookie() одновременно (это установит куки дважды в одном запросе). Выберите один из методов.

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

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

1
2
3
4
5
6
7
8
9
10
11
12
// 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
  • XML
  • PHP
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

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

Веб-API

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

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

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

1
$ composer require api

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 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`:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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);

        // ...
    }
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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

Так как MercureBundle поддерживает несколько автобусов, вам может понадобиться заменить другие определения сервисов, соответственно.

Отладка

0.2

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

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

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

1
$ composer require --dev symfony/debug-pack

Асинхронное развёртывание

Tip

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 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!