跳到内容

使用 Mercure 协议向客户端推送数据

编辑此页

对于许多现代 Web 和移动应用程序来说,能够将数据从服务器实时广播到客户端是一项基本要求。

创建对其他用户所做的更改做出实时反应的 UI (例如,一个用户更改了其他几个用户当前正在浏览的数据,所有 UI 都会立即更新)、在 异步任务 完成时通知用户或创建聊天应用程序是需要“推送”功能的典型用例。

Symfony 提供了一个基于 Mercure 协议 构建的简单组件,专门为这类用例而设计。

Mercure 是一个从头开始设计的开放协议,用于从服务器向客户端发布更新。它是基于定时器的轮询和 WebSocket 的现代高效替代方案。

由于它构建于 服务器发送事件 (SSE) 之上,Mercure 在现代浏览器中开箱即用 (旧版本的 Edge 和 IE 需要 polyfill),并且在许多编程语言中都有 高级实现

Mercure 带有授权机制、在出现网络问题时自动重新连接并检索丢失的更新、presence API、智能手机的“无连接”推送和自动发现功能 (受支持的客户端可以借助特定的 HTTP 标头自动发现和订阅给定资源的更新)。

所有这些功能都在 Symfony 集成中得到支持。

在此录像中,您可以看到 Symfony Web API 如何利用 Mercure 和 API Platform 来实时更新使用 API Platform 客户端生成器生成的 React 应用和移动应用 (React Native)。

安装

安装 Symfony Bundle

运行此命令以安装 Mercure 支持

1
$ composer require mercure

运行 Mercure Hub

为了管理持久连接,Mercure 依赖于 Hub:一个专用的服务器,用于处理与客户端的持久 SSE 连接。Symfony 应用程序将更新发布到 Hub,Hub 将其广播给客户端。

在生产环境中,您必须自行安装 Mercure Hub。可以从 Mercure.rocks 下载基于 Caddy Web 服务器的官方开源 (AGPL) Hub 的静态二进制文件。还提供了 Docker 镜像、Kubernetes 的 Helm chart 和托管的高可用性 Hub。

由于 Symfony 的 Docker 集成Flex 建议安装 Mercure Hub 用于开发。如果您选择了此选项,请运行 docker-compose up 以启动 Hub。

如果您使用 Symfony 本地 Web 服务器,则必须使用 --no-tls 选项启动它。

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

如果您使用 Docker 集成,则 Hub 已经启动并运行。

配置

配置 MercureBundle 的首选方法是使用 环境变量

安装 MercureBundle 后,您的项目的 .env 文件已由 Flex recipe 更新,以包含可用的环境变量。

此外,如果您将 Docker 集成与 Symfony 本地 Web 服务器、Symfony DockerAPI Platform 发行版 结合使用,则已自动设置正确的环境变量。直接跳到下一节。

否则,将您的 Hub 的 URL 设置为 MERCURE_URLMERCURE_PUBLIC_URL 环境变量的值。有时,Symfony 应用程序 (通常用于发布) 和 JavaScript 客户端 (通常用于订阅) 必须调用不同的 URL。当 Symfony 应用程序必须使用本地 URL,而客户端 JavaScript 代码必须使用公共 URL 时,这种情况尤其常见。在这种情况下,MERCURE_URL 必须包含 Symfony 应用程序使用的本地 URL (例如 https://mercure/.well-known/mercure),而 MERCURE_PUBLIC_URL 必须包含公开可用的 URL (例如 https://example.com/.well-known/mercure)。

客户端还必须带有 JSON Web Token (JWT) 到 Mercure Hub,以便获得发布更新的授权,有时还需要获得订阅的授权。

此令牌必须使用与 Hub 用于验证 JWT 的密钥相同的密钥进行签名 (!ChangeThisMercureHubJWTSecretKey! 如果您使用 Docker 集成)。此密钥必须存储在 MERCURE_JWT_SECRET 环境变量中。MercureBundle 将使用它来自动生成和签署所需的 JWT。

除了这些环境变量之外,MercureBundle 还提供了更高级的配置

  • secret:用于签署 JWT 的密钥 - 必须使用与哈希输出大小相同的密钥 (例如,“HS256”为 256 位) 或更大的密钥。(除 algorithmsubscribepublish 之外的所有其他选项都将被忽略)
  • publish:生成 JWT 时允许发布到的主题列表 (仅当提供了 secretfactory 时可用)
  • subscribe:生成 JWT 时允许订阅的主题列表 (仅当提供了 secretfactory 时可用)
  • algorithm:用于签署 JWT 的算法 (仅当提供了 secret 时可用)
  • provider:用于调用以提供 JWT 的服务的 ID (所有其他选项都将被忽略)
  • factory:用于调用以创建 JWT 的服务的 ID (除 subscribepublish 之外的所有其他选项都将被忽略)
  • value:要使用的原始 JWT (所有其他选项都将被忽略)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# config/packages/mercure.yaml
mercure:
    hubs:
        default:
            url: '%env(string:MERCURE_URL)%'
            public_url: '%env(string:MERCURE_PUBLIC_URL)%'
            jwt:
                secret: '%env(string:MERCURE_JWT_SECRET)%'
                publish: ['https://example.com/foo1', 'https://example.com/foo2']
                subscribe: ['https://example.com/bar1', 'https://example.com/bar2']
                algorithm: 'hmac.sha256'
                provider: 'My\Provider'
                factory: 'My\Factory'
                value: 'my.jwt'

提示

JWT payload 必须至少包含以下结构,客户端才能被允许发布

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

jwt.io 网站是创建和签署 JWT 的便捷方式,请查看此 JWT 示例。不要忘记在表单右侧面板的底部正确设置您的密钥!

基本用法

发布

Mercure 组件提供了一个 Update 值对象,表示要发布的更新。它还提供了一个 Publisher 服务,用于将更新分发到 Hub。

可以使用 自动装配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) 序列化资源。

订阅

从 Twig 模板在 JavaScript 中订阅更新非常简单

1
2
3
4
5
6
7
<script>
const eventSource = new EventSource("{{ mercure('https://example.com/books/1')|escape('js') }}");
eventSource.onmessage = event => {
    // Will be called every time an update is published by the server
    console.log(JSON.parse(event.data));
}
</script>

mercure() Twig 函数根据配置生成 Mercure Hub 的 URL。该 URL 包含与作为第一个参数传递的主题相对应的 topic 查询参数。

如果您想从外部 JavaScript 文件访问此 URL,请在专用的 HTML 元素中生成该 URL

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>
{# Subscribe to updates of several Book resources and to all Review resources matching the given pattern #}
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>

但是,在客户端 (即在 JavaScript 的 EventSource 中),没有内置方法来知道特定消息来自哪个主题。如果这 (或任何其他元信息) 对您很重要,则需要在消息的数据中包含它 (例如,通过向 JSON 添加密钥,或向 HTML 添加 data-* 属性)。

提示

使用 在线调试器 测试 URI 模板是否与 URL 匹配

提示

Google Chrome 具有实用的 UI 来显示接收到的事件

The Chrome DevTools showing the EventStream tab containing information about each SSE event.

在 DevTools 中,选择“网络”选项卡,然后单击对 Mercure Hub 的请求,然后单击“EventStream”子选项卡。

发现

Mercure 协议带有发现机制。要利用它,Symfony 应用程序必须在 Link HTTP 标头中公开 Mercure Hub 的 URL。

您可以使用 Discovery 助手类创建 Link 标头 (在底层,它使用 WebLink 组件)

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 discover(Request $request, Discovery $discovery): JsonResponse
    {
        // Link: <https://hub.example.com/.well-known/mercure>; rel="mercure"
        $discovery->addLink($request);

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

然后,可以在客户端解析此标头以查找 Hub 的 URL 并订阅它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Fetch the original resource served by the Symfony web API
fetch('/books/1') // Has Link: <https://hub.example.com/.well-known/mercure>; rel="mercure"
    .then(response => {
        // Extract the hub URL from the Link header
        const hubUrl = response.headers.get('Link').match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1];

        // Append the topic(s) to subscribe as query parameter
        const hub = new URL(hubUrl, window.origin);
        hub.searchParams.append('topic', 'https://example.com/books/{id}');

        // Subscribe to updates
        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 publish(HubInterface $hub): Response
    {
        $update = new Update(
            'https://example.com/books/1',
            json_encode(['status' => 'OutOfStock']),
            true // private
        );

        // Publisher's JWT must contain this topic, a URI template it matches or * in mercure.publish or you'll get a 401
        // Subscriber's JWT must contain this topic, a URI template it matches or * in mercure.subscribe to receive the update
        $hub->publish($update);

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

要订阅私有更新,订阅者必须向 Hub 提供 JWT,其中包含与更新主题匹配的主题选择器。

为了提供此 JWT,订阅者可以使用 cookie 或 Authorization HTTP 标头。

Symfony 可以通过将适当的选项传递给 mercure() Twig 函数来自动设置 Cookie。如果 EventSource 类的 withCredentials 属性设置为 true,则 Symfony 设置的 Cookie 会自动传递给 Mercure Hub。然后,Hub 验证提供的 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...)

当客户端是 Web 浏览器时,使用 Cookie 是最安全和首选的方式。如果客户端不是 Web 浏览器,那么使用授权标头是可行的方法。

警告

要使用 cookie 身份验证方法,Symfony 应用程序和 Hub 必须从同一域提供服务 (可以是不同的子域)。

提示

EventSource 的原生实现不允许指定标头。例如,使用 Bearer 令牌进行授权。为了实现这一点,请使用 polyfill

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

有时,从您的代码中设置授权 cookie 而不是使用 Twig 函数可能很方便。MercureBundle 提供了一个方便的服务 Authorization 来执行此操作。

在以下示例控制器中,添加的 cookie 包含一个 JWT,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'
        ]);
    }
}

提示

您不能同时使用 mercure() 助手和 setCookie() 方法 (它会在单个请求中设置两次 cookie)。选择其中一种方法即可。

以编程方式生成用于发布的 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 getJwt(): string
    {
        return 'the-JWT';
    }
}

然后,在 bundle 配置中引用此服务

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

此方法在使用具有过期日期的令牌时尤其方便,令牌可以通过编程方式刷新。

Web API

在创建 Web API 时,能够立即将新版本的资源推送给所有连接的设备并更新其视图非常方便。

API Platform 可以使用 Mercure 组件自动分发更新,每次创建、修改或删除 API 资源时都会这样做。

首先使用其官方配方安装库

1
$ composer require api

然后,创建以下实体就足以获得功能齐全的超媒体 API,并通过 Mercure hub 自动更新广播

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 Platform Client Generator 还允许从此 API 脚手架完整的 React 和 React Native 应用程序。这些应用程序将实时渲染 Mercure 更新的内容。

查看专门的 API Platform 文档以了解有关其 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(): void
    {
        $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);

        // ...
    }
}

对于功能测试,您可以改为创建 Hub 的存根

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// tests/Functional/Stub/HubStub.php
namespace App\Tests\Functional\Stub;

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 替换默认的 hub 服务,这样实际上不会发送任何更新

1
2
3
4
# config/services_test.yaml
services:
    mercure.hub.default:
        class: App\Tests\Functional\Stub\HubStub

由于 MercureBundle 支持多个 hub,您可能需要相应地替换其他服务定义。

提示

Symfony Panther 具有一项功能来测试使用 Mercure 的应用程序

调试

0.2

WebProfiler 面板在 MercureBundle 0.2 中引入。

MercureBundle 附带一个调试面板。安装 Debug pack 以启用它

1
$ composer require --dev symfony/debug-pack
The Mercure panel of the Symfony Profiler, showing information like time, memory, topics and data of each message sent by Mercure.

异步调度

提示

不鼓励异步分发。大多数 Mercure hub 已经异步处理发布,通常不需要使用 Messenger。

除了直接调用 Publisher 服务之外,您还可以借助提供的与 Messenger 组件的集成,让 Symfony 异步分发更新。

首先,请确保安装 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'])
        );

        // Sync, or async (Doctrine, RabbitMQ, Kafka...)
        $bus->dispatch($update);

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

更进一步

  • Notifier 组件也支持 Mercure 协议Notifier 组件。使用它向 Web 浏览器发送推送通知。
  • Symfony UX Turbo 是一个使用 Mercure 的库,提供与单页应用程序相同的体验,但无需编写一行 JavaScript 代码!
这项工作,包括代码示例,均根据 Creative Commons BY-SA 3.0 许可获得许可。
目录
    版本