使用 Go、SSE 和 htmx 實時更新網站

banq發表於2024-06-14


在這個例子中,我們將在伺服器端呈現 HTML,除了 htmx 庫之外不使用任何 JavaScript 程式碼進行互動。

完整示例在GitHub上。您可以使用 docker-compose 在本地執行它。

使用的工具

  • Echo — 我喜歡的輕量級 HTTP 路由器,它的錯誤處理比 更簡單net/http。
  • templ — 一個基於程式碼生成的 HTML 模板庫。有時它可能會很奇怪,但總的來說,我對它很滿意,比 更喜歡它html/template。最好與 IDE 外掛一起使用。
  • htmx — 一個無需編寫 JavaScript 即可使用 AJAX 和 SSE 的庫。
  • Watermill——我們維護的用於處理訊息的事件驅動庫。
  • PostgreSQL和Google Cloud Pub/Sub用於儲存和訊息傳遞基礎設施。(您可以選擇不同的 Pub/Sub 進行訊息傳遞,甚至是 Postgres。)

在設計 SSE 端點時,您必須決定有效負載應該是什麼、何時傳送更新以及向誰傳送。

有效負載
有效負載只是文字,如何編碼由您決定。它可以是常規的 JSON API 響應,也可以是直接嵌入網站的 HTML。請記住,每行都應有字首data:,並且有效負載需要以兩個新行 ( \n\n) 結尾。

您需要一種方法來了解應用程式中何時發生更改,以便推送更新。例如,如果使用者收到一條訊息,您會在 UI 中顯示一個紅色氣泡。

SSE 端點是長期執行的,因此您可能有數百或數千個 goroutine 在後臺執行,您必須通知它們發生更改。作為響應,每個 goroutine 都應向客戶端傳送一個事件。由於您可能正在執行多個服務例項,因此這無法在記憶體中工作。

您通常只想通知某些使用者發生的事情。如果我向您傳送訊息,我希望通知出現在您的螢幕上,但不會出現在其他任何人的螢幕上。因此,您需要一種方法來過濾發生的事情並選擇誰應該獲得更新(以及觸發哪些 SSE 端點)。

在此示例中,其過程如下:

  • 內容:帖子“統計”模型,包括 HTML 格式的瀏覽量和反應數量。
  • 時間:當有人看到帖子或對其做出反應時。
  • 收件人:所有看到更新帖子的人。其他帖子未更新。

實現 SSE 端點
雖然您完全可以從頭開始(或使用庫)建立 SSE 端點,而且它並不複雜,但困難的部分是觸發更新以響應發生的事情。(並且透過網路執行此操作,因為在生產中很少執行單個服務例項。)與執行 HTTP 伺服器一樣,您不想在這裡重新發明輪子。

我們通常使用Watermill來處理與事件相關的任何事情。這是我們維護的一個 Go 庫,它抽象了 Pub/Subs 的低階細節。(GitHub 上的星星數量接近 7k⭐️)。您可以將它與任何現有程式碼庫一起使用,因為它不是一個框架,而是一個輕量級庫(就像 htmx 一樣)。它支援許多 Pub/Subs,因此可以輕鬆地從您已有的基礎設施(甚至是 SQL 資料庫)開始。

Watermill
首先,你需要一個Pub/Sub — 一個讓你處理網路上訊息的系統(也稱為“訊息代理”或“佇列”)。常見的選擇是 Kafka 或 RabbitMQ,但它也可以是一個 SQL 資料庫。

Watermill 將所有 Pub/Sub 抽象為兩個介面:

type Publisher interface {
    Publish(topic string, messages ...*Message) error
    Close() error
}

type Subscriber interface {
    Subscribe(ctx context.Context, topic string) (<-chan *Message, error)
    Close() error
}

您可以釋出資訊,也可以訂閱資訊。其中總會涉及到一個主題--一個決定誰會收到資訊的字串。

Watermill 的核心部分是 "訊息"。它就像 net/http 軟體包中的請求(Request)。最簡單的訊息只有一個可選 ID 和一個有效載荷。有效載荷是一片位元組,因此可以使用任何你想要的編排方式(JSON、協議緩衝區、純字串等)。

msg := message.NewMessage(watermill.NewUUID(), []byte("Hello, world!"))

雖然 Watermill 的所有元件都基於釋出者和訂閱者介面,但直接使用它們是相對低階的 API。在本例中,我們將使用 Watermill 的 CQRS 元件,它是更高階別的 API。它基於相同的理念,但去除了一些模板,如序列化和反序列化。我們將使用 EventBus 釋出事件,並使用 EventProcessor 訂閱事件。

我們希望釋出兩項事件活動:

  • PostViewed 在有人看到帖子時釋出。
  • PostReactionAdded 在有人對帖子做出反應時釋出。

每個事件都有一個事件處理程式,用於更新資料庫中的帖子統計資訊(與 HTTP 處理程式的概念類似)。(處理程式還應釋出 PostStatsUpdated 事件。我們將用它來觸發 SSE 更新。

釋出事件
首先,讓我們建立一個釋出者。我使用的是 Google Cloud Pub/Sub Publisher,但它也可以與 Watermill 支援的任何其他 Publisher 互換使用。配置只需要一個專案 ID。

(請注意,Google Cloud Pub/Sub 只是 Watermill 支援的 Pub/Sub 之一。您可以輕鬆將其更改為其他支援的 Pub/Sub。就像 ORM 可以與 MySQL、PostgreSQL 和 SQLite 一起使用一樣)。

logger := watermill.NewStdLogger(false, false)

publisher, err := googlecloud.NewPublisher(
    googlecloud.PublisherConfig{
        ProjectID: cfg.PubSubProjectID,
    },
    logger,
)


釋出器處理訊息,這意味著您必須將事件(結構)編組為位元組並選擇將它們釋出到哪些主題。對所有遵循某些約定的事件和主題使用相同的編組是很常見的,例如事件名稱是其中的一部分。

我們將使用EventBus元件來簡化釋出 API。您可以將其視為釋出者的高階包裝器(如您所見,它是第一個引數)。您只需傳遞一次配置選項,然後就可以使用單個方法呼叫釋出事件。

eventBus, err := cqrs.NewEventBusWithConfig(
    publisher,
    cqrs.EventBusConfig{
        GeneratePublishTopic: func(params cqrs.GenerateEventPublishTopicParams) (string, error) {
            return params.EventName, nil
        },
        Marshaler: cqrs.JSONMarshaler{},
        Logger:    logger,
    },
)

配置需要一個 Marshaler,因此我們使用 cqrs.JSONMarshaler{}(所有訊息都將被 marshal 為 JSON)。

GeneratePublishTopic 函式會根據可用引數返回主題名稱。我們沒有直接向 Publish 傳遞主題,而是定義了這個函式來根據訊息確定主題。每次釋出訊息時,EventBus 都會呼叫該函式。在本例中,我們將使用 params.EventName。因此,如果你考慮這樣一個結構

type PostViewed struct {
    PostID int `json:<font>"post_id"`
}

它將釋出在 PostViewed 主題上。(所選 marshaler 提供了提取事件名稱的方法)。

使用事件匯流排釋出事件非常簡單。有了 marshaler 和 GeneratePublishTopic 的設定,我們就可以將事件結構傳遞給 Publish,剩下的就在幕後完成了。在 HTTP 處理程式中,我們可以使用類似下面這樣的功能:

event := PostViewed{
    PostID: post.ID,
}

err = h.eventBus.Publish(ctx, event)

訂閱事件
我決定讓 HTTP 端點只發布事件。事件處理程式會非同步更新資料庫中的帖子統計資訊。這樣,客戶端就無需等待更改的應用,而檢視最終將透過 SSE 更新。

我們需要兩個事件處理程式來更新資料庫中的統計資訊。第一個處理程式更新檢視計數,第二個處理程式更新反應計數。

用於訂閱事件的 CQRS 元件是 EventProcessor。與 EventBus 一樣,最初也需要進行一些設定。但有了它,以後編寫處理程式就會非常輕鬆。它背後的理念與 EventBus 類似,但卻是相反的。

首先,建立一個路由器。它的概念與大家熟悉的 HTTP 路由器類似。該元件在後臺執行,並將訊息路由到處理程式。

router, err := message.NewRouter(message.RouterConfig{}, logger)

與 HTTP 路由器類似,Watermill 路由器也支援中介軟體。例如,你可以新增 Recoverer 中介軟體,這樣處理程式中的恐慌就不會炸燬你的伺服器。

router.AddMiddleware(middleware.Recoverer)

現在我們可以建立 EventProcessor。

eventProcessor, err := cqrs.NewEventProcessorWithConfig(
    router, 
    cqrs.EventProcessorConfig{
        GenerateSubscribeTopic: func(params cqrs.EventProcessorGenerateSubscribeTopicParams) (string, error) {
            return params.EventName, nil
        },
        SubscriberConstructor: func(params cqrs.EventProcessorSubscriberConstructorParams) (message.Subscriber, error) {
            return googlecloud.NewSubscriber(
                googlecloud.SubscriberConfig{
                    ProjectID: cfg.PubSubProjectID,
                    GenerateSubscriptionName: func(topic string) string {
                        return fmt.Sprintf(<font>"%v_%v", topic, params.HandlerName)
                    },
                },
                logger,
            )
        },
        Marshaler: cqrs.JSONMarshaler{},
        Logger:    logger,
    },
)

第一個引數是路由器。這類似於 EventBus 對釋出者的 "包裝"。

然後是配置。Marshaler 和 GenerateSubscribeTopic 與 EventBus 中的概念相同。唯一不同的是,它們位於 Pub/Sub 的另一端。在事件匯流排中,Marshaler 會對訊息進行 Marshal,然後由函式決定將其釋出到哪個主題。而在這裡,Marshaler 會將訊息解除安裝回結構體,然後由 GenerateSubscribeTopic 決定向哪個主題訂閱。

SubscriberConstructor 正如其名:它返回一個新的訂閱者。你可能會問,為什麼不使用單個訂閱者,就像我們在 EventBus 中使用的釋出者那樣呢?

釋出訊息非常簡單:你只需 marshal 一個結構體,將位元組傳送到一個主題,然後就大功告成了。訂閱則是更有趣的地方。例如,同一服務有兩個副本。如何確保只有一個副本收到來自 Pub/Sub 的訊息?

策略取決於 Pub/Sub。在谷歌雲 Pub/Sub 中,您可以使用繫結到主題的單個 "訂閱",並在副本之間共享。這就是為什麼訂閱者建構函式在這種情況下非常有用。它允許我們為每種事件型別指定使用哪種訂閱。在本例中,訂閱將主題名稱與處理程式名稱連線起來。例如,PostViewed_UpdateViews。

正如承諾的那樣,設定完成後,新增訊息處理程式就非常簡單了。請注意,函式是通用的(具有推斷型別),因此您可以使用強型別事件!處理程式名稱用於生成訂閱名稱,因此在處理程式中必須是唯一的。

err = eventProcessor.AddHandlers(
    cqrs.NewEventHandler(
        <font>"UpdateViews"
        func(ctx context.Context, event *PostViewed) error {
            return repo.UpdatePost(ctx, event.PostID, func(post *Post) {
                post.Views++
            })
        },
    ),
    cqrs.NewEventHandler(
       
"UpdateReactions",
        func(ctx context.Context, event *PostReactionAdded) error {
            return repo.UpdatePost(ctx, event.PostID, func(post *Post) {
                post.Reactions[event.ReactionID]++
            })
        },
    ),
)

最後一部分是執行路由器,就像執行 HTTP 伺服器一樣。

go func() {
    err := router.Run(context.Background())
    if err != nil {
        panic(err)
    }
}()

釋出帖子統計已更新
我們將使用另一個事件來觸發 SSE 更新:PostStatsUpdated。它包括帖子的 ID 和已更新內容的記錄(檢視次數或反應 ID)。

type PostStatsUpdated struct {
    PostID          int     `json:<font>"post_id"`
    ViewsUpdated    bool    `json:
"views_updated"`
    ReactionUpdated *string `json:
"reaction_updated"`
}

更新帖子後,雙方處理人員都應釋出該PostStatsUpdated事件。

err = eventProcessor.AddHandlers(
    cqrs.NewEventHandler(
        <font>"UpdateViews",
        func(ctx context.Context, event *PostViewed) error {
            err = repo.UpdatePost(ctx, event.PostID, func(post *Post) {
                post.Views++
            })
            if err != nil {
                return err
            }

            statsUpdated := PostStatsUpdated{
                PostID:       event.PostID,
                ViewsUpdated: true,
            }

            return eventBus.Publish(ctx, statsUpdated)
        },
    ),
    cqrs.NewEventHandler(
       
"UpdateReactions",
        func(ctx context.Context, event *PostReactionAdded) error {
            err := repo.UpdatePost(ctx, event.PostID, func(post *Post) {
                post.Reactions[event.ReactionID]++
            })
            if err != nil {
                return err
            }

            statsUpdated := PostStatsUpdated{
                PostID:          event.PostID,
                ReactionUpdated: &event.ReactionID,
            }

            return eventBus.Publish(ctx, statsUpdated)
        },
    ),
)

SSE 路由器
現在是時候實現 SSE 端點了。Watermill 還提供了一個可以與其他內部元件很好地配合使用的 SSE 元件。

主要元件稱為 SSE Router,其背後的想法非常簡單。當您呼叫其AddHandler方法時,它會訂閱配置的訂閱者中的給定主題。該方法返回一個常規 HTTP 處理程式,您可以將其與您想要的任何 HTTP 路由器一起使用。每當所選主題中出現訊息時,它將以扇出方式傳播到其中所有正在執行的 SSE 端點。

....更多點選標題

配置訂閱者
還記得我們希望每個事件一次僅由一個服務副本處理的部分嗎?對於用於 SSE 的事件,您需要一種違反直覺的方法:所有訂閱者都需要處理每個事件,因為 SSE 端點將在所有服務例項上執行。

...更多點選標題

頁面
難題的最後一部分是客戶端程式碼。


其他需要考慮的事項
兩種 SSE 端點
如何從 SSE 端點返回事件完全由您決定。以下是兩種適用於不同場景的方法。

  1. 一個端點,在最初和每次更新時都返回相同的資料模型。每次觸發更新時,您都會“重新整理”模型,也許嵌入在網站上。這就是我們在上例中使用的。
  2. 一個端點,最初不返回任何內容,然後在發生更新時不斷髮送唯一更新。例如,您可以將每個新事件附加到某個列表中。這就是您實現通知或網路聊天的方式。

至少一次投遞
在使用幾乎任何 Pub/Sub 時,您必須注意“至少一次”的交付保證。您可能會因為網路問題或伺服器在錯誤的時間停機而兩次收到同一條訊息。
不要試圖繞過這個問題。相反,接受這種情況的發生,並將你的處理程式設計為冪等的。這意味著處理訊息兩次(或更多)與處理一次具有相同的效果。

在上面的例子中,我們沒有對此進行防範。如果同一條訊息被處理兩次,它會在資料庫中新增額外的檢視或反應。在這種情況下,這不是什麼大問題,我們可以忍受。一種防止這種情況發生的方法是將已處理的訊息 ID 儲存在資料庫中,並在每次更新時檢查它。

注意HTTP/1.1
在現代瀏覽器中,HTTP/1.1 上每個伺服器最多可開啟 6 個連線,這在使用 SSE 時可能會成為大問題。如果有人在多個標籤頁中開啟您的網站,您的網站將無法正常執行。

為獲得最佳效果,請在不受此限制的情況下使用 SSE 和 HTTP/2。大多數現代網路伺服器都支援 HTTP/2,因此請確保啟用它。

更多點選標題

相關文章