理解 Python 後端技術: ASGI (Asynchronous Server Gateway Interface) — WSGI 的繼承者

Posted on  Jun 2, 2024  in  Python 程式設計 - 中階  by  Amo Chen  ‐ 10 min read

大家或多或少應該都聽過近年來熱門的 Python 框架 FastAPI,它其中一個特點是支援 WebSocket 。

WebSocket 與 HTTP 的不同之處在於, HTTP 是單次要求單次回應的協定,而 WebSocket 是 1 種長期連線技術,允許多次的客戶端與伺服器端互動事件發生,這意味著應用需要能夠處理持續的雙向通訊,而不僅僅是處理單一的要求和回應。

談到這裡,不知道你是否會好奇為什麼 FastAPI 能夠同時支援這 2 種截然不同的協定?

這一切都與 ASGI 脫不了關係!

本文將介紹 ASGI (Asynchronous Server Gateway Interface) ,這個被稱為 WSGI 繼承者(successor)的規範,以及 ASGI 如何賦予 FastAPI 同時支援 HTTP 與 WebSocket 的能力。

本文環境

$ pip install 'uvicorn[standard]'

前言

本文是「理解 Python 後端技術:從 CGI 談到 WSGI, uWSGI 與 uwsgi」的後續之作,強烈建議讀者在閱讀本文之前先閱讀前文,因為 ASGI 是在 WSGI 的基礎上演進而來,許多概念需要先理解 WSGI 才能更好地掌握 ASGI。

ASGI (Asynchronous Server Gateway Interface) 簡介

自從 WSGI 這份規格在 2003 年問世之後, WSGI 也確實完美地在 Python 生態系中落地生根,更成就了 Django, Flask 等框架與 gunicron, uWSGI 等 WSGI HTTP servers, 對 Python 生態系的貢獻, WSGI 絕對有其一席之地。

隨著技術演進與為了滿足使用者需求改善體驗,現代應用不僅僅使用 HTTP 協定,也可能會使用 WebSockets, SSE 這些長期連線(long-lived connections)的技術,這些長期連線的技術恰好是 WSGI 的短板,因為 WSGI 的 callable object 只接受 1 個 request 並回傳 1 個 response, 而長期連線技術則可能會需要回應多個 responses 之外,也可能需要持續接受來自 client 端的多個事件(events)。

此外, WSGI 使用的是 synchronous callable object, 對於已經支援 asyncio 的 Python 來說,顯然無法完全發揮 Python 的潛能。

Its primary goal is to provide a way to write HTTP/2 and WebSocket code alongside normal HTTP handling code, however; part of this design means ensuring there is an easy path to use both existing WSGI servers and applications, as a large majority of Python web usage relies on WSGI and providing an easy path forward is critical to adoption.

綜合上述原因, ASGI 是做為 WSGI 的繼承者(successor)的 1 份規格(specification),目標是能夠處理一般 HTTP requests 之外,也能夠支援包含 HTTP/2, WebSocket 等長期連線的技術。

當然, ASGI 也維持 WSGI 一貫的精神:

  1. 簡單易實作
  2. 向下相容( ASGI 相容 WSGI )

所以 ASGI 也規定 ASGI server 必須能夠呼叫/執行 WSGI application ,所以你寫的 Django, Flask 等應用,是可以改成使用 ASGI server 的。

值得一提的是, ASGI 目前還沒有 PEP 文件,只有一份 ASGI Documentation 可以參閱。

總結一下:

  1. ASGI 是 1 份規格,希望改善 WSGI 不支援 asynchronous callable 的情況,增加對 WebSocket, HTTP/2 等長期連線技術的支援。
  2. ASGI 可以相容 WSGI 。
  3. ASGI 沒有 PEP 文件。
  4. ASGI 規格目前版本為 3.0, 舊版為 2.0

ASGI (Asynchronous Server Gateway Interface) 寫些什麼?

如果你有看過 PEP-333 - Python Web Server Gateway Interface 的話,其實會發現 ASGI 大致上架構與 WSGI 雷同,雖然用詞有些不一樣,但也是主要分為 2 個部分:

  1. Application
  2. Protocol server

其實跟 WSGI 一樣都是分別對實作 ASGI 規格的 Server 與 Application 的規範。

如果要開發像 FastAPI 之類的 ASGI Framework 或 Application, 只要遵守 application 的規定就好。

如果要開發像 uvicorn, Hypercorn 之類的 ASGI Web server, 則是遵守 protocol server 的規定。

以下會分開進行解釋。

Application 的規範

ASGI Application 的規範與 WSGI 類似,也是規定 application 必須是 1 個 callable object, 只不過這個 callable object 是 asynchronous callable object, 也就是說它能夠使用 await 關鍵字進行呼叫。

ASGI asynchronous callable object 的形式如下:

async def application(scope, receive, send):
    ...

可以看到它接受 3 個參數,分別是:

  1. scope
  2. receive
  3. send
參數 scope 代表 1 個連線的相關資訊

Every connection by a user to an ASGI application results in a call of the application callable to handle that connection entirely. How long this lives, and the information that describes each specific connection, is called the connection scope.

ASGI 的設計是 1 個連線(connection)就交由 1 個 application callable 處理,只要這個連線沒有斷,那麼都是同 1 個 application callable 處理 request 與回應 response 。

Closely related, the first argument passed to an application callable is a scope dictionary with all the information describing that specific connection.

所以 scope 存放 1 個連線的所有相關資訊,如果客戶端使用 HTTP 協定,那麼 scope 裡面也會存放 request 的資料,包含 request headers, body 等等,有點類似 WSGI 的 environ 作用,只是 WSGI 的 environ 存的是關於 1 個 request 的所有資料,而 ASGI 的 scope 存的是關於 1 個連線的所有資料。

與 WSGI 的 environ 相同, ASGI 的 scope 也是 1 個 dictionary ,而且裡面至少會有 1 個名稱為 type 的 key 存在, type 代表這個連線使用何種協定(protocol)連線,之所以需要 type 就如前文所示, ASGI 目標是可以處理多種協定,例如 HTTP, WebSocket 等等。

目前 type 的值可以是 http , websocket 或者 lifespan

p.s. lifespan 不是 protocol, 它是用來通知 application 啟動(startup)或關閉(shutdown)的特殊 type

而除了 type 之外,也會有 ASGI 專屬的 key asgi ,裡面會存放 ASGI 的相關資訊,例如 version 與 spec_version, 所以 scope 最起碼會長得像以下 dictionary:

scope = {
    'type': 'http', # or websocket, lifespan
    'asgi': {
        'version': '3.0',  # '2.0' 是舊版
        'spec_version': '2.3',
    }
}

scope['asgi']['version'] 代表 Sever 實作的 ASGI 規格,而 scope['asgi']['spec_version'] 代表 ASGI server 實作的 protocol 的版本。

versionspec_version 的差異在於:

  1. ASGI 規格在 2.0 以前,呼叫 application 只會傳入 1 個 scope 參數,而 2.0 以後則是同時傳入 scope, receive, send 3 個參數,所以用 version 代表 Server 實作的 ASGI 規格版本,這也代表 ASGI Server 會如何呼叫 application 。
  2. 因為 ASGI 支援 HTTP, WebSocket 協定, ASGI 所規定的協定與資料格式也會有版本演進,例如 2.3 可以在 WebSocket close 事件加上 reason ,簡而言之, spec_version 影響的是 ASGI server 與 ASGI application 之間溝通的資料格式。

以下是 1 個實際的 HTTP 的 scope 完整樣貌,其實可以發現 scope 與 WSGI 的 environ 作用相似,也是存 method, query_strin , headers 等資訊:

{
    "type": "http",
    "asgi": {"version": "3.0", "spec_version": "2.3"},
    "http_version": "1.1",
    "server": ("127.0.0.1", 8000),
    "client": ("127.0.0.1", 59098),
    "scheme": "http",
    "root_path": "",
    "headers": [
        (b"host", b"localhost:8000"),
        (b"connection", b"keep-alive"),
        (
            b"sec-ch-ua",
            b'"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"',
        ),
        (b"sec-ch-ua-mobile", b"?0"),
        (b"sec-ch-ua-platform", b'"macOS"'),
        (b"upgrade-insecure-requests", b"1"),
        (
            b"user-agent",
            b"...",
        ),
        (
            b"accept",
            b"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
        ),
        (b"accept-encoding", b"gzip, deflate, br, zstd"),
        (b"accept-language", b"en-US,en;q=0.9,zh-TW;q=0.8,zh;q=0.7"),
        (
            b"cookie",
            b"...",
        ),
        (b"dnt", b"1"),
        (b"sec-gpc", b"1"),
    ],
    "state": {},
    "method": "GET",
    "path": "/",
    "raw_path": b"/",
    "query_string": b"",
}

更多關於 scope 的格式請參閱 HTTP & WebSocket ASGI Message Format

更多關於 spec_version 請閱讀 ASGI - Spec Versions

參數 receive 是 1 個接收 event 的 awaitable callable

當我們需要接收從客戶端發出的 event 時,就需要用 await 關鍵字呼叫 receive, 它會在有 event 時 yield 1 個 event dictionary 。

event dictionary 的內容則依 scope['type'] 的值有所不同,但不管是什麼內容,都會有 1 個相同的 key type

如果是 scope['type']http 的話, event dictionary 的 type 則可能是 http.request ; 如果 scope['type']websocket 的話, event dictionary 的 type 則可能是 websocket.send

以下是 1 個實際的 HTTP 的 event dictionary, 可以看到 receive() 所回傳的 event dictionary 存著 HTTP request 的 body:

{"type": "http.request", "body": b"", "more_body": False}

值得注意的是 more_body key 代表還有沒有額外的資料待傳,如果值是 True, 代表 Application 要繼續接收額外的資料,直到值為 False 為止。如果值為 False 就代表沒有其他資料了,可以開始處理 request 。

基本上,可以認為 receive 是接收 request 的 awaitable callable 。

參數 send 是 1 個回應 event 的 awaitable callable

如果要進行回應(response),就需要用 await 關鍵字呼叫 send

WSGI 的規格中,規定 application 回應的方式是:

  1. 先呼叫 start_response
  2. 再 return response body
def app(environ, start_response):
    start_response("200 OK", [('Content-Type', 'text/html'),])
    return [
        b"<html>",
        b"<body>",
        b"<h1>Hello, WSGI</h1>",
        b"</body>",
        b"</html>",
    ]

到了 ASGI 則是統一透過 send awaitable callable 進行,上述 WSGI application 的範例,改為 ASGI application 則是:

async def app(scope, receive, send):
    assert scope['type'] == 'http'

    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [
            [b'content-type', b'text/html'],
        ],
    })
    await send({
        'type': 'http.response.body',
        'body': b'<html><body><h1>Hello, ASGI</h1></body></html>',
    })

從上述範例可以看到 send awaitable callable 接受 1 個參數,該參數也是 1 個 event dictionary, 代表我們要回應什麼給客戶端,回應的格式則被規定在 HTTP & WebSocket ASGI Message Format 中,其實也不外乎使用 key type 代表什麼 event 與其他依 scope['http'] 決定的格式,例如 HTTP 就需要 status, headers 等。

值得注意的是,ASGI application 並不像 WSGI application 在處理完 request 後就 return 結束執行, ASGI application 可以多次呼叫 receivesend 與客戶端持續互動,這就賦予 ASGI application 處理長期連線的能力,這一點我們稍後做個範例展示。

實作一個 ASGI HTTP application 試試

知道 ASGI application 的規格之後,可以實作 1 個 ASGI HTTP application 體驗一下 ASGI, 以下是 1 個 ASGI HTTP application 的程式碼,是 1 個僅會顯示 Hello, ASGI! 的網頁:

app.py

async def app(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [
            [b'content-type', b'text/html'],
        ],
    })
    await send({
        'type': 'http.response.body',
        'body': b'<html><body><h1>Hello, ASGI!</h1></body></html>',
    })

接著,我們使用 uvicorn 執行上述 ASGI HTTP application:

$ uvicorn app:app

執行成功的話,可以看到以下輸出,可以看到 ASGI Server 正在監聽 127.0.0.1:8000

INFO:     Started server process [66393]
INFO:     Waiting for application startup.
INFO:     ASGI 'lifespan' protocol appears unsupported.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

我們可以使用瀏覽器輸入網址 http://127.0.0.1:8000 ,就能夠看到以下畫面:

hello-asgi.png

這就是 1 個簡單的 ASGI HTTP application 。

做個 ASGI WebSocket application 試試

HTTP 協定對 ASGI Server 來說,只是基本小菜。

ASGI 的亮點在於能支援 WebSocket 等長期連線。

所以,做 1 個 ASGI WebSocket application 體驗ㄧ下吧!

以下是本範例的資料夾結構,請將接下來會使用的 2 份檔案放在同 1 個資料夾下:

.
├── app.py
└── index.html

首先,我們需要 1 個可以建立 WebSocket 連線的網頁,以下是 1 個極簡單的 WebSocket Client 的 HTML + JavaScript 程式碼:

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WebSocket Client</title>
  </head>
  <body>
    <h1>WebSocket Client Example</h1>
    <div id="messages"></div>
    <script>
      // Create a new WebSocket connection
      const socket = new WebSocket('ws://127.0.0.1:8000');
      const messagesDiv = document.getElementById('messages');

      // When the connection is open, log it to the console
      socket.addEventListener('open', (event) => {
        console.log('Connected to the WebSocket server');

        // Send an event every 2 seconds
        setInterval(() => {
          const currentTime = new Date().toLocaleTimeString();
          const message = `Hello, server! Current time is ${currentTime}`;
          console.log('Sending message:', message);
          socket.send(message);
        }, 2000);
      });

      // Listen for messages from the server
      socket.addEventListener('message', (event) => {
        console.log('Message from server:', event);
        const messageElement = document.createElement('p');
        messageElement.textContent = `Received: ${event.data}`;
        messagesDiv.appendChild(messageElement);
      });

      // Handle any errors that occur
      socket.addEventListener('error', (error) => {
        console.error('WebSocket error:', error);
      });

      // Log when the connection is closed
      socket.addEventListener('close', (event) => {
        console.log('WebSocket connection closed:', event);
        const messageElement = document.createElement('p');
        messageElement.textContent = 'Connection closed.';
        messagesDiv.appendChild(messageElement);
      });

      setTimeout(() => {
        socket.close();
      }, 20000)
    </script>
  </body>
</html>

上述 HTML + JavaScript 程式碼,會與正在監聽 127.0.0.1:8000 的 ASGI server 建立 WebSocket 連線:

const socket = new WebSocket('ws://127.0.0.1:8000');

剩下的 JavaScript 程式碼作用則是主要是每 2 秒向 WebSocket server 傳送 message, 如果收到來自 WebSocket server 的 message 時,就將收到的訊息附加到網頁上顯示,最後則是 20 秒後自動關閉 WebSocket 連線。

有了 WebSocket client 之後,我們可以進一步打造可以處理 HTTP 與 WebSocket 連線的 ASGI application, 以下是其程式碼:

app.py

from datetime import datetime


with open('./index.html', 'rb') as f:
    INDEX_HTML = b''.join(f.readlines())


async def app(scope, receive, send):
    if scope['type'] == 'http':
        await http_app(scope, receive, send)
    elif scope['type'] == 'websocket':
        await websocket_app(scope, receive, send)


async def http_app(scope, receive, send):
    event = await receive()
    if event['type'] == 'http.request':
        # Send HTTP response start
        await send({
            'type': 'http.response.start',
            'status': 200,
            'headers': [
                [b'content-type', b'text/html'],
            ],
        })

        # Send HTTP response body
        await send({
            'type': 'http.response.body',
            'body': INDEX_HTML,
        })


async def websocket_app(scope, receive, send):
    # Accept the WebSocket connection
    await send({
        'type': 'websocket.accept',
    })
    while True:
        event = await receive()
        if event['type'] == 'websocket.receive':
            now = datetime.now().strftime("%m/%d/%Y, %H:%M:%S")
            # send the message back
            await send({
                'type': 'websocket.send',
                'text': '[ASGI Server | {time}] Pong!'.format(time=now),
            })
        elif event['type'] == 'websocket.disconnect':
            break

上述程式碼會按照 scope['type'] 的值不同,交由不同的 coroutine 處理,如果值是 http 就會交由 http_app 處理 connection, 如果值是 websocket 則交由 websocket_app 處理 connection 。

http_app 的作用很單純,統一回應 index.html 給客戶端,讓客戶端可以在瀏覽器載入建立 WebSocket 連線的 JavaScript 程式碼。

websocket_app 的作用則是在收到 WebSocket 的 connect event 時,先回應 websocket.accept 給客戶端,代表連線建立;接著,進入無窮 while loop 持續接收客戶端傳來的 event 。如果 event type 是 websocket.receive , 就回應客戶端 [ASGI Server | 回應時間] 格式的資料;如果 event type 是 websocket.disconnect 就跳出 while loop 結束 connection 。

p.s. 這些 event types 都被規定在 ASGI specification 之中,本文專注於介紹 ASGI 的規格與運作原理,因此不會介紹這些 events

接著,同樣用 uvicorn 執行能夠處理 HTTP 與 WebSocket 的 ASGI application:

$ uvicorn app:app

同樣打開瀏覽器並輸入網址 http://127.0.0.1:8000 , 可以看到以下畫面的話,就代表運作正常,我們的 ASGI application 具備處理 HTTP 與 WebSocket 的能力:

http-websocket-demo.png

這個範例告訴我們 1 件事:

我們能夠用同 1 個方式處理 HTTP 與 WebSocket 2 種截然不同的協定,這呼應了 ASGI Specification 中提到的:

Its primary goal is to provide a way to write HTTP/2 and WebSocket code alongside normal HTTP handling code

最後,值得再提一次的是 ASGI application callable object 在建立 1 個新的 connection 時,才會呼叫 1 次,並不是每發生 1 次 event 就呼叫一次,所以在前述範例中,總共呼叫 2 次 ASGI application callable object, 1 次是處理 HTTP request, 另 1 次則是處理 WebSocket connection 。

相信讀到此處,各位應該都知道 ASGI application 具體是如何運作的了。

接著,看看 FastAPI 是如何實作 ASGI application 的,以下是 FastAPI 最簡單的程式碼:

from fastapi import FastAPI

app = FastAPI()

當我們建立 FastAPI class 的實例(instance)時,其實也建立了 ASGI application callable object, 因為 FastAPI class 實作的 __call__ 方法(source code),就是 ASGI 所規定的形式:

fastapi-callable.png

當 ASGI server 接到 connection 時,就會按照 ASGI 的規定把相關參數帶入 app 之中,也就是 app(scope, receive, send) ,剩下的就是框架如何處理 connection 的自家事了。

p.s. 實際上 FastAPI 此處最後是交由另 1 個 Python 套件 Starlette 所定義的 Starlette 類別處理(source code)

p.s. Starlette 是 1 個輕量級的 ASGI 框架, FastAPI 是基於 Starlette 框架所演進的框架

這就是 ASGI application 的介紹囉!

那 ASGI Protocol Server 做了什麼?

其實從 ASGI application 就可以知道 ASGI Protocol Server 主要做了 2 件事:

  1. 把關於 connection 的資訊整理成 scope 的格式,準備呼叫 ASGI application 時使用
  2. 準備好 receivesend 2 個 awaitable callable object 給 ASGI application 使用

我們可以從 uvicorn 的原始碼看到這 2 件事,我們舉 uvicorn 實作的 HTTP protocol 為例。

以下 2 個連結可以看到 uvicorn 所準備的 receivesend 2 個 awaitable callable object:

負責整理 scope 的部分則是定義在 handle_events 方法中:

uvicorn-h11.png

而真正呼叫 ASGI application callable object 的則是 self.cycle.run_asgi(app), 進一步追查 self.cycle.run_asgi(app)原始碼就會發現是 ASGI 規定的形式:

uvicorn-invoke-app.png

這就是 ASGI protocol 所做的事。

相容 WSGI 的部分呢?

ASGI is also designed to be a superset of WSGI, and there’s a defined way of translating between the two, allowing WSGI applications to be run inside ASGI servers through a translation wrapper (provided in the asgiref library). A thread pool can be used to run the synchronous WSGI applications away from the async event loop.

雖然 ASGI 有寫到 ASGI 是 WSGI 的 superset, 也有設計方法可以將 WSGI 轉成 ASGI, 但實際上還是需要一些小程度的改動,例如在 WSGI application callable object 多包 1 層 wrapper, 當作兩者的轉換介面。

uvicorn 原本有實作 WSGI 的介面,不過目前已經移除該功能,但在 uvicorn 的官方文件仍有給出如何轉換 WSGI 指引,開發者可以用 a2wsgi 套件作轉換,轉換可以雙向:

  • 將 ASGI application 轉成 WSGI application
  • 將 WSGI application 轉成 ASGI application

如果要將 WSGI application 轉成 ASGI application 的話,可以使用以下程式碼:

from a2wsgi import WSGIMiddleware

ASGI_APP = WSGIMiddleware(WSGI_APP)

以下是 1 個實際的轉換範例:

app.py

from a2wsgi import WSGIMiddleware

def wsgi_app(environ, start_response):
    start_response("200 OK", [('Content-Type', 'text/html'),])
    return [
        b"<html>",
        b"<body>",
        b"<h1>Hello, WSGI</h1>",
        b"</body>",
        b"</html>",
    ]

app = WSGIMiddleware(wsgi_app)

上述範例同樣可以用 uvicorn ASGI protocol server 執行:

$ uvicorn app:app

而轉換的原理也很單純,其實就是把 WSGI application callable object 以 1 個類別 class 包裝起來,再實作 __call__ 方法,使其符合 ASGI application callable object 的形式(原始碼):

wsgi2asgi.png

當然, __call__ 方法內就需要實作能將 ASGI application 參數轉換成與 WSGI application 等價作用的功能,也就是上述 WSGIResponder 實際上在做的事情。

然後呼叫 WSGI application callable object 時,其實是放到另外的 thread pool 中執行(原始碼),如此就不會阻塞 ASGI protocol server 的 event loop:

invoke-the-wsgi-app.png

以上,就是關於 ASGI 如何相容 WSGI 的介紹。

總結

ASGI(Asynchronous Server Gateway Interface)作為 Python 生態系中 WSGI 的下一代介面,催生了許多框架和 Servers 。熱門的 FastAPI, uvicorn, Hypercorn, Django Channels 等等都是基於 ASGI 規範建構的。

理解 ASGI 就與理解 WSGI 同樣重要,因為 ASGI 解決了 WSGI 在支援 asyncio 和長期連接技術方面的不足,ASGI 不僅解放 Python 應用的效能,還為開發帶來了極大的彈性,使得開發者可以在同 1 個 codebase 中實作 HTTP 與 WebSocket 相關的功能!

總而言之,ASGI 的出現為現代應用提供了更強大的工具和更靈活的選擇。未來隨著更多開發者採用 ASGI, 我們將看到更多創新的應用出現。

以上!

Enjoy!

References

ASGI (Asynchronous Server Gateway Interface) Specification — ASGI 3.0 documentation

Uvicorn

FastAPI

Starlette

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!