跳到內容

使用 Mercure 協定將資料推送至用戶端

編輯此頁面

對於許多現代化的網路和行動應用程式而言,能夠從伺服器即時廣播資料到用戶端是一項必要功能。

建立即時反應其他使用者所做變更的 UI (例如,當一位使用者變更目前正被其他多位使用者瀏覽的資料時,所有 UI 都會立即更新)、在非同步工作完成時通知使用者,或是建立聊天應用程式,都是需要「推送」功能的典型用例。

Symfony 提供了一個簡單明瞭的元件,建立在 Mercure 協定之上,專門為此類用例設計。

Mercure 是一個開放協定,從底層設計用於從伺服器發布更新到用戶端。它是基於計時器的輪詢和 WebSocket 的現代化且有效率的替代方案。

由於 Mercure 是建立在伺服器發送事件 (SSE) 之上,因此在現代瀏覽器中開箱即用 (舊版本的 Edge 和 IE 需要一個 polyfill),並且在許多程式語言中都有高階實作。

Mercure 具備授權機制、在發生網路問題時自動重新連線並擷取遺失的更新、存在 API、「無連線」智慧型手機推送,以及自動探索功能 (支援的用戶端可以透過特定的 HTTP 標頭自動探索和訂閱給定資源的更新)。

所有這些功能都在 Symfony 整合中受到支援。

在這段錄影中,您可以看到 Symfony Web API 如何運用 Mercure 和 API Platform 即時更新使用 API Platform 用戶端產生器產生的 React 應用程式和行動應用程式 (React Native)。

安裝

安裝 Symfony 套件

執行此命令以安裝 Mercure 支援

1
$ composer require mercure

執行 Mercure Hub

為了管理持久連線,Mercure 依賴 Hub:一個專用的伺服器,用於處理與用戶端的持久 SSE 連線。Symfony 應用程式將更新發布到 Hub,Hub 會將其廣播給用戶端。

在生產環境中,您必須自行安裝 Mercure Hub。基於 Caddy Web 伺服器的官方開放原始碼 (AGPL) Hub 可以從 Mercure.rocks 下載為靜態二進位檔案。也提供了 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 Docker 或 API 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 酬載必須至少包含以下結構,用戶端才能被允許發布

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,或將 data-* 屬性新增至 HTML)。

提示

使用線上偵錯工具測試 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,以將更新標記為private

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!');
    }
}

若要訂閱 private 更新,訂閱者必須向 Hub 提供包含主題選取器的 JWT,該選取器與更新的主題相符。

若要提供此 JWT,訂閱者可以使用 Cookie 或 Authorization HTTP 標頭。

Cookie 可以由 Symfony 自動設定,方法是將適當的選項傳遞給 mercure() Twig 函式。如果 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';
    }
}

然後,在套件設定中參考此服務

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 資源時都會分發更新。

首先使用其官方 recipe 安裝程式庫

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 用戶端產生器也允許從此 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 的 Stub

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 套件以啟用它

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 協定。使用它將推播通知傳送到 Web 瀏覽器。
  • Symfony UX Turbo 是一個使用 Mercure 的程式庫,可提供與單頁應用程式相同的體驗,但無需編寫任何 JavaScript 程式碼!
這份作品,包括程式碼範例,均根據創用 CC BY-SA 3.0 授權條款授權。
目錄
    版本