iT邦幫忙

2022 iThome 鐵人賽

DAY 8
0
Modern Web

這些那些你可能不知道我不知道的Web技術細節系列 第 8

你可能不知道的即時更新方案:Long Polling

Long Polling

Polling 有兩個明顯的缺點:

  1. 就算資料沒有更新,也會有一次 Request / Response 的來回。
  2. 資料有了更新,也需要等待下一次的Request才會知道。

Long Polling解決了這兩個問題。

雖然行為概觀上與Polling一樣,是一次Request後得到Response,才再發出一個Request。但是Reponse的回傳可能會拉的老長,過了許久才回應,像彗星(Comet)一樣。也確實有一種方式就叫做彗星(Comet),Long Polling是他的變種[^6]。

在Long Polling模式下,有一個長期連線,這個連線在資料一更新時,變會回傳Response,並結束此輪連線,然後再發起一次長連線請求,以做到即時更新的效果。

有一段時間在Facebook、Plurk可以見得此種方式。甚至現在還在Facebook、Plurk還是可以見得一些Request長時間沒有Response,不過我無法確定是否同為Long Polling。

下圖是Plurk的部份網路請求節圖。其中可以看到一個/connect相關的請求,並過一段時間後才返回Response,隨後又建立一比連線:

優點

與Polling一樣,應用架構或許需要微調,但不需要調整多少,且多數瀏覽器支援。並且實質意義上做到即時更新的效果。

缺點

Long Polling長期佔用連線。一般對於伺服器而言,有連線數上限的問題。在使用與建構上需要考量水平服務擴張的能力。

此外,每次更新後,都需要再重新發起一次連線。儘管較少,但同樣有多次TCP的三段式交握連線的負擔。

Lab

要建立Long Polling的實驗環境就不能簡單靠Nginx、Apach Web Sever等網頁伺服器了。會需要一個網站應用框架,這裡選擇FastAPI,因此你需要有Python的環境,然後安裝fastapi

pip install fastapi uvicorn watchdog

前端畫面

沿用Polling的index.html並做一些調整修改。

<!-- www-data/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8"/>
    <title>即時更新內容 - Long Polling</title>
  </head>
  <body>
    <h1 id="content"></h1>
  </body>
  <script defer type="module">
   const delay = 0 /*ms*/;
   const contentEl = document.querySelector('#content');
   let timer = null;
   let lastUpdate = undefined;

   async function updateContent(){
       let headers = {};
       headers['If-Modified-Since'] = lastUpdate;

       try {
           let response = await fetch('/connect', { headers } )
           const data = await response.json();
           lastUpdate = data.lastModified;
           contentEl.innerText = data.message;
       } catch (e) {
           console.error(e);
       }

       timer = setTimeout( updateContent , delay);
   }

   updateContent();

  </script>
</html>

首先是delay不再需要了,因此設為0。在一次更新結束後,立刻建立一個長連線。

這次的回傳訊息,除了內容本身外,有一個更新時間。現在必須儲存下來,用以告知伺服器訂閱更新的需求。添加lastUpdate用於記錄。在請求的時候,簡單的將記錄添加於請求頭中:

headers['If-Modified-Since'] = lastUpdate;

稍微把Endpoint也換個名稱--/connect表明用於長連線,訂閱更新訊息。

在收到回應後(Response),訊息本身(message)同樣顯示於畫面之上。並且更新lastUpdate

後端API

引入一些必要的package。這裡會使用watchdog去監看檔案是否有被修改。

from typing import Union, TypedDict
from fastapi import FastAPI, Header, Response
from fastapi.responses import HTMLResponse, FileResponse
from watchdog.events import LoggingEventHandler
from watchdog.observers import Observer
import os
import time

然後是一些常數、變數。

CONTENT_FILE = 'content.txt'
GMT_FORMAT = '%a, %d %b %Y %H:%M:%S GMT'

app = FastAPI()

接著簡單的提供index.html檔案。

@app.get('/index.html', response_class=HTMLResponse)
async def index():
    return FileResponse('index.html')

通常而言靜態檔案不會由應用框架提供,會直接由專門的Web Server提供。在FastAPI也有關於靜態檔案的處理方式。只是這裡簡化處理。


然後是重頭戲/connect的部分。會先比對檔案是否有被更改過:

@app.get('/connect')
def connect(response: Response,
            if_modified_since: Union[str, None] = Header(default=None)):
    mtime = os.path.getmtime(CONTENT_FILE)
    mtime = time.gmtime(mtime)
    last_modified = time.strftime(GMT_FORMAT, mtime)

    if if_modified_since is None or \
       if_modified_since != last_modified:
        pass

更改的時間是否與要求一致?是的話表示並沒有被更改過,那就可以直接回傳內容:

        msg = ''
        with open(CONTENT_FILE) as f:
            msg = f.read();
        return {'lastModified': last_modified, 'message': msg}

否則的話就去監看檔案,直到檔案被修改都保持著連線:

    mtime = watch(CONTENT_FILE)  # 這裡會保持連線
    mtime = time.gmtime(mtime)
    last_modified = time.strftime(GMT_FORMAT, mtime)

特別是watch是當檔案被更改後才會繼續執行後續動作:

class FileStat(TypedDict):
    modified: bool = False
    
def watch(file_path: str):
    # ......
    try:
        while file_stat["modified"] is False:
            time.sleep(1)
    # ......
    observer.stop()
    return os.path.getmtime(CONTENT_FILE)

對於watchdog,只是簡單的監聽更改事件,當檔案有所變動後,就會調整參考的狀態:

def watch(file_path: str):
    file_stat: FileStat = { "modified": False }
    observer = Observer()
    observer.schedule(WatchDogEvent(file_stat), CONTENT_FILE, recursive=False)

    observer.start()
    # ......
    try:
        # ......
    # ......
    observer.stop()
    return os.path.getmtime(CONTENT_FILE)


class WatchDogEvent(LoggingEventHandler):
    def __init__(self, file_stat: FileStat):
        self.file_stat: FileStat = file_stat

    def on_modified(self, event):
        self.file_stat["modified"] = True

最後同樣地,將結果回傳給瀏覽器:


@app.get('/connect')
def connect(response: Response,
            if_modified_since: Union[str, None] = Header(default=None)):
    # ......
    msg = ''
    with open(CONTENT_FILE) as f:
        msg = f.read();
    return {'lastModified': last_modified, 'message': msg}

DEMO

現在可以嘗試啓動服務器看看

uvicorn app:app

開啓瀏覽器瀏覽 http://localhost:8000/index.html

你會注意到,總是更新過後才有一個新的Request。這樣子減少了來回的次數,並提高了即時性。

參考資料

[^6]: 獲得實時更新的方法(Polling, Comet, Long Polling, WebSocket)。取用時間:2022.09.04。

本文同時發表於我的隨筆


上一篇
你可能不知道的即時更新方案:Polling
下一篇
你可能不知道的即時更新方案:Server Send Event
系列文
這些那些你可能不知道我不知道的Web技術細節33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言