從零開始編寫一個 Python 非同步 ASGI WEB 框架

.Hanabi發表於2023-10-27

從零開始編寫一個 Python 非同步 ASGI WEB 框架

前言

本著 「路漫漫其修遠兮,吾將上下而求索」 的精神,這次要和朋友們分享的內容是《從零開始編寫一個 Python 非同步 ASGI WEB 框架》。

近來,我被 Python 的非同步程式設計深深吸引,花了兩個多月的時間研究了大量資料並閱讀了一些開源框架的原始碼,受益匪淺。

在工作中,我常常忘記特定框架提供的方法,也不願意頻繁檢視官方文件,因此我決定嘗試自己編寫一個簡單的非同步 WEB 框架,以滿足我這懶惰的心態和對並不複雜的業務需求。

在我準備開始編寫非同步 WEB 框架之際,我曾對 aiohttp 抱有近乎崇拜的情感。

本著 "膜拜, 學習, 借鑑" 的精神,我克隆了 aiohttp 的原始碼。然而,讓我感到驚訝的是,aiohttp 並不是基於 ASGI 實現的 WEB 伺服器。

這意味著在學習 aiohttp 構建自己的 WEB 框架時,我必須從 asyncio 提供的底層 TCP 套接字一步一步地實現 HTTP 協議。

對我當時而言,這顯得有點繁瑣。因此,在深思熟慮後,我決定基於 ASGI 協議編寫我的首個 WEB 框架。

這篇文章大致總結了我從零開始到目前已經完成的工作,對於那些對 Python WEB 或非同步程式設計有興趣或希望更深入瞭解的朋友們來說,應該會有所幫助。

該文章可分為兩大部分:前置知識和框架編寫實踐。

在前置知識部分,我將介紹一些基本概念,如 WSGI、ASGI 等,以幫助初學者更好地理解這些概念。

在框架編寫實踐部分,我將著重介紹程式碼編寫過程中的思考,而不過多涉及具體的實現細節。

如果您感興趣,可以在 GitHub 上克隆我的框架原始碼,結合本文閱讀,相信您將獲益良多。

前置知識

認識 WSGI

官方文件

如果您曾學習過像 Django、Flask 這樣在 Python 中非常著名的 WEB 框架,那麼您一定聽說過 WSGI。

實際上,WSGI 是一種規範,它使得 Python 中的 WEB 伺服器和應用程式解耦,讓我們在編寫應用程式時更加方便、高效,也更具通用的可移植性。

例如,下面是一個快速使用 Python 內建模組 wsgiref 處理 HTTP 請求的示例:

def simple_wsgi_app(environ, start_response):
    # 設定響應狀態和頭部
    status = '200 OK'
    headers = [('Content-type', 'text/plain')]
    start_response(status, headers)

    # 返回響應內容
    return [b'Hello, World!']


if __name__ == '__main__':
    from wsgiref.simple_server import make_server

    # 建立WSGI伺服器
    with make_server('', 8000, simple_wsgi_app) as httpd:
        print("Serving on port 8000...")
        httpd.serve_forever()

在這裡,我們使用 wsgiref 提供的 make_server 建立了一個簡單的 WEB 伺服器,並傳入一個應用程式 simple_wsgi_app (這是一個函式)。

在執行程式碼時,WSGI 伺服器會監聽 8000 埠。一旦有請求到達,它將自動執行 simple_wsgi_app 函式,該函式只是簡單地返回響應內容。

儘管這段程式碼相對簡單,但對於初學者可能仍有一些疑惑。

例如,應用程式在哪裡?

實際上,WSGI 協議規定:一個應用程式介面只是一個 Python 可呼叫物件,它接受兩個引數,分別是包含 HTTP 請求資訊的字典(通常稱為 environ)和用於傳送 HTTP 響應的回撥函式(通常稱為 start_response)。

因此,在使用 WSGI 協議編寫程式碼時,我們只需要關注如何處理業務層面的資訊(即如何處理請求和返回響應),而無需過多關心底層細節。

認識 ASGI

官方文件

隨著 Python 非同步技術的興起,WSGI 慢慢失去了原有的光彩。這主要由以下兩個不足之處導致:

  • WSGI 是同步的,當一個請求到達時,WSGI 應用程式會阻塞處理該請求,直到完成並返回響應,然後才能處理下一個請求。
  • WSGI 只支援 HTTP 協議,只能處理 HTTP 請求和響應,不支援 WebSocket 等協議。

因此,Django 開發團隊提出並推動了 ASGI 的構想和發展。相對於 WSGI,ASGI 的優勢在於:

  • 純非同步支援,這意味著基於 ASGI 的應用程式具有無與倫比的效能。
  • 長連線支援,ASGI 原生支援 WebSocket 協議,無需額外實現獨立於協議的功能。

ASGI 的目標是最終取代 WSGI,但目前 Python 的非同步生態並不夠成熟,因此這是一個充滿希望但漫長的過程。

鑑於目前 Python 的內建模組中沒有提供 ASGI 協議的伺服器實現。因此,我們可以選擇一些第三方模組,如:

  • uvicorn: FastApi 選擇的 ASGI 伺服器實現
  • hypercorn: Quart 選擇的 ASGI 伺服器實現
  • granian: 一款使用 Rust 的 Python ASGI 伺服器實現

實際上,對於這些繁多的 ASGI 伺服器實現選擇並不需要太過糾結,因為對於編寫應用程式的我們來說,它們的用法都是相似的。

以下是一個基於 uvicorn 的 ASGI 應用程式的最小可執行示例程式碼:

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

        # 設定響應狀態和頭部
        await send({
            'type': 'http.response.start',
            'status': 200,
            'headers': [
                [b'content-type', b'text/plain'],
            ],
        })

        # 返回響應內容
        await send({
            'type': 'http.response.body',
            'body': b'Hello, world!',
        })

if __name__ == '__main__':
    from uvicorn import run
    run(app="setup:app")

可以看到,ASGI 應用程式的編寫方式與 WSGI 應用程式非常相似,它們的目的是相同的,即讓我們編寫應用程式更加方便。

ASGI 應用程式是一個非同步可呼叫物件,接受三個引數:

  • scope: 連結範圍字典,至少包含一個 type 表明協議型別
  • receive: 一個可等待的可呼叫物件,用於讀取資料
  • send: 一個可等待的可呼叫物件,用於傳送資料

要使用 ASGI 協議編寫應用程式,最關鍵的是要理解這一協議的具體規範。

因此,強烈建議對此感興趣的朋友先閱讀官方文件中的介紹。

在這裡,我簡要提取了一些重要資訊。

首先,scope 非常重要,它包含一個 type 欄位,目前常見的值有 3 個,分別是 http、websocket、lifespan 等。

lifespan 基礎介紹

在 Application 執行後,lifespan 會率先執行。它是 ASGI 提供的生命週期 hooks,可用於在 Application 啟動和關閉時執行一些操作。

以下是一個簡單的示例:

async def startup():
    # 回撥函式,在服務啟動時
    print("Startup ...")

async def shutdown():
    # 回撥函式,在服務關閉時
    print("Shutdown ...")

async def app(scope, receive, send):
    if scope["type"] == "http":
        pass
    elif scope["type"] == "websocket":
        pass
    elif scope["type"] == "lifespan":
        # 每次 Application 被動執行,都會啟動一個新的 asyncio task,因此無需擔心阻塞主協程
        while True:
            message = await receive()
            # 應用程式啟動了
            if message["type"] == "lifespan.startup":
                try:
                    await startup()
                    # 反饋啟動完成
                    await send({"type": "lifespan.startup.complete"})
                except Exception as exc:
                    # 反饋啟動失敗
                    await send({"type": "lifespan.startup.failed", "message": str(exc)})
            # 應用程式關閉了
            elif message["type"] == "lifespan.shutdown":
                try:
                    await shutdown()
                    # 反饋關閉完成
                    await send({"type": "lifespan.shutdown.complete"})
                except Exception as exc:
                    # 反饋關閉失敗
                    await send({"type": "lifespan.shutdown.failed", "message": str(exc)})
                finally:
                    break

if __name__ == "__main__":
    from uvicorn import run
    run("setup:app", lifespan=True)

Lifespan 的應用非常廣泛,通常我們會在 shutdown 階段關閉一些資源,比如 aiohttp 的 session、資料庫的連線池等等。

恰當地使用 lifespan 可以讓您的 WEB 框架更加靈活和具有更大的擴充套件性。

http 的 scope

當一個 HTTP 請求到來時,其 scope 是什麼樣的呢?讓我們來看一下下面的程式碼塊,或者您也可以查閱 官方文件 HTTP 的 scope 部分

{
    'type': 'http',
    'asgi': {                              # ASGI 協議版本
        'version': '3.0',
        'spec_version': '2.3'
    },
    'http_version': '1.1',                 # HTTP 協議版本
    'server': ('127.0.0.1', 5000),         # 伺服器的主機和埠
    'client': ('127.0.0.1', 51604),        # 本次請求的客戶端的主機和埠
    'scheme': 'http',                      # URL 的 scheme 部分,可以是 http 或 https,但肯定不為空
    'method': 'GET',                       # 請求方法
    'root_path': '',                       # 類似於 WSGI 的 SCRIPT_NAME
    'path': '/index/',                     # 請求路徑
    'raw_path': b'/index/',                # 原始請求路徑
    'query_string': b'k1=v1&k2=v2',        # 請求字串
    'headers': [                           # 請求頭
        (b'host', b'127.0.0.1:5000'),
        (b'user-agent', b'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0'),
        (b'accept', b'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'),
        (b'accept-language', b'en-US,en;q=0.5'),
        (b'accept-encoding', b'gzip, deflate, br'),
        (b'connection', b'keep-alive'),
        (b'upgrade-insecure-requests', b'1'),
        (b'sec-fetch-dest', b'document'),
        (b'sec-fetch-mode', b'navigate'),
        (b'sec-fetch-site', b'cross-site')
    ],
    'state': {}
}

很顯然,ASGI 伺服器已經對請求資訊進行了初步過濾,這使得我們可以在 Application 中靈活地利用這些資訊。

開始編寫框架

編寫框架前的思考

在著手編寫框架之前,良好的前期設計將省去許多麻煩。在編寫這個框架時,我首先思考了一個請求的生命週期是什麼樣的?

這導致了下面這張圖的產生:

由於 ASGI 協議僅提供了相對基本的功能,因此在這之上還需要考慮很多事情,如:

  • 路由器如何實現?
  • 如何讀取表單資料?
  • 請求物件應該如何實現?
  • 鉤子函式應該如何設計?
  • 是否需要支援 ORM?
  • 是否需要支援模板引擎?
  • 是否需要支援基於類的檢視?
  • 是否需要支援訊號處理?

關於第一點,路由功能,我選擇使用第三方模組 http-router,它是一個非常簡單但實用的模組,提供了子路由、動態引數、正則匹配、標籤轉換、嚴格模式等常見功能。

它滿足了我大部分需求,至少在目前階段我很滿意。如果後續有不滿意之處,我可能會基於該模組進行擴充套件或自己實現一些功能(例如新增路由別名功能)。

關於第二點,表單資料如何讀取,實際上,不僅是表單資料,包括上傳檔案的讀取,本身都是一項繁瑣且枯燥的工作。因此,我選擇使用 python-multipart 模組來快速實現這個功能。

唯一的小遺憾是,該模組只支援同步語法,但這並不是大問題。由於 Python 非同步生態目前並不成熟,故很多非同步框架中也使用了同步的第三方模組或其他同步解決方案。所以只要設計得當,同步語法對效能的影響並不會像我們想象的那麼大。

關於第三點,請求物件如何實現,我非常欣賞 Flask 中的 request 物件採用的上下文方式。相對於傳統的將 request 例項傳遞給檢視處理函式,上下文方式更加靈活且優雅。不過,實現上下文方式會有一定的難度,框架內部如何實現和使用上下文將是一個有挑戰性的問題。

關於第四點,鉤子函式的設計,我參考了 Flask、FastAPI 和 Django 中的鉤子函式設計。我認為鉤子函式越簡單越好,因此目前除了 ASGI 的生命週期鉤子外,我只想實現 exception、after_request 和 before_request 這三個鉤子函式。

關於第五點,是否需要支援 ORM,我認為一個出色的 WEB 框架應該具備良好的可擴充套件性。目前,Python 非同步生態中有許多非同步 ORM 的選擇,它們應該以外掛的形式嵌入到 WEB 框架中,而不是 WEB 框架應提供的必備功能之一。

關於第六點,是否需要支援模板引擎,我的觀點與第五點類似。特別是在現代的前後端分離方案中,模板引擎是一項錦上添花的功能,不一定是必需的。另一方面是我工作的原因,我已經有很多年沒有編寫過模板程式碼了,綜合考慮來看對於這一點的結果我是持否定意見的。

關於第七點,是否需要支援基於類的檢視,我認為這是必須的。這應該是一款出色的 WEB 框架必須提供的選項之一,無論是後期需要實現 RESTful API 還是其他需求,它都應該是必需的。

關於第八點,是否需要支援訊號處理,我仍然認為這是必需的。但這是一個後期的實現點,當有業務需求不得不使用訊號處理進行回撥時(例如保證雙寫一致性時),訊號回撥是高效且有用的方法,它非常方便。

讓我們開始編寫 Application

Application 應當儘量保持簡單,因此它本身只需要實現非外掛提供的 3 個主要功能:

  • 提供一個初始化方法,用於儲存第三方外掛或一些使用者的設定資訊等。
  • 將不同的 ASGI scope type 分發到不同的 HandleClass 中。
  • 提供一個 run 函式用以啟動服務。

如下所示:

...
from uvicorn import run as run_server
...

class Application:
    def __init__(self, name, trim_last_slash=False):
        # trim_last_slash 是否嚴格匹配路由,這是外掛帶來的功能,這裡透傳一下
        self.name = name
        # 忽略、事件管理器的實現
        self.event_manager = EventManager()
        # 忽略、Router 的實現
        self.router = Router(trim_last_slash)
        self.debug = False

    async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend):
        # 這裡主要是對不同的 scope type 做出處理
        # 為什麼是 __call__ 方法中編寫呢?因為一個 instance object 物件加括號呼叫就會
        # 自動執行其 __call__ 方法
        if scope["type"] == "http":
            asgi_handler = AsgiHttpHandle(self)
        elif scope["type"] == "websocket":
            asgi_handler = AsgiWebsocketHandle(self)
        elif scope["type"] == "lifespan":
            asgi_handler = AsgiLifespanHandle(self)
        else:
            raise RuntimeError("ASGI Scope type is unknown")
        # 呼叫 Handle 的 __call__ 方法
        await asgi_handler(scope, receive, send)

    def run(
        self,
        app: Optional[str | Type["Application"]] = None,
        host: str = "127.0.0.1",
        port: int = 5200,
        debug: bool = False,
        use_reloader: bool = True,
        ssl_ca_certs: Optional[str] = None,
        ssl_certfile: Optional[str] = None,
        ssl_keyfile: Optional[str] = None,
        log_config: Optional[Union[Dict[str, Any], str]] = LOGGING_CONFIG,
        **kwargs: Any
    ):
        # 該方法主要是對 uvicorn 的 run_server 做了一層高階封裝
        # 列印一些資訊、做出一些合理設定等等
        # 讓使用者能更快速的啟動整個框架

        self.debug = debug
        app = app or self

        # 列印一些啟動或者歡迎語句,看起來更酷一點
        terminal_width, _ = shutil.get_terminal_size()
        stars = "-" * (terminal_width // 4)

        scheme = "https" if ssl_certfile is not None and ssl_keyfile is not None else "http"
        print(f"* Serving Razor app '{self.name}'")
        print(f"* Please use an ASGI server (e.g. Uvicorn) directly in production")
        print(f"* Debug mode: {self.debug or False}")
        print(f"* Running on \033[1m{scheme}://{host}:{port}\033[0m (CTRL + C to quit)")

        # uvicorn 中若啟用熱過載功能,則 app 必須是一個 str 型別
        # 如果是在非 debug 模式下,我會自動關閉掉熱過載功能
        if not isinstance(app, str) and (use_reloader or kwargs.get("workers", 1) > 1):
            if not self.debug:
                print("* Not in debug mode. Automatic reloading is disabled.")
            else:
                print("* You must pass the application as an import string to enable 'reload' or " "'workers'.")

            use_reloader = False

        print(stars)

        # 啟動 uvicorn 的 WEB Server 當有請求到來時會自動執行 __call__ 方法
        # 因為我們傳入的 Application 是當前的例項物件 self,
        run_server(
            app,
            host=host,
            port=port,
            reload=use_reloader,
            ssl_certfile=ssl_certfile,
            ssl_keyfile=ssl_keyfile,
            ssl_ca_certs=ssl_ca_certs,
            log_config=log_config,
            **kwargs
        )

此外,還有一些第三方外掛的二次封裝方法:

class Application:
    ...

    def route(self, *paths, methods=None, **opts):
        """
        Normal mode routing

        Simple route:
            - @app.route('/index')

        Dynamic route:
            - @app.route('/users/{username}')

        Converter route:
            - @app.route('/orders/{order_id:int}')

        Converter regex route:
            - @app.route('/orders/{order_id:\\d{3}}')

        Multiple path route:
            - @app.route('/doc', '/help')

        Class-Based View it will be processed automatically:
            - @app.route('/example')
              class Example:
                  ...
        """
        # 這個方法主要是透傳到 self.router 中,不對資料來源做任何處理
        return self.router.route(*paths, methods=methods, **opts)

    def re_route(self, *paths, methods=None, **opts):
        """
        Regex mode routing

        @app.re_route('/regexp/\\w{3}-\\d{2}/?')
        """
        # 同上
        return self.route(
            *(re.compile(path) for path in paths),
            methods=methods,
            **opts
        )

    def add_routes(self, *routes):
        """
        Add routing relationship mappings in

事件管理器的實現

事件管理器的實現較為簡單,但十分有效。大道至簡,不必花裡胡哨:

from .exceptions import RegisterEventException

# 支援的被註冊事件
EVENT_TYPE = (
    # lifespan event
    "startup",
    "shutdown",
    # http event
    "after_request",
    "before_request",
    # exception event
    "exception"
)

class EventManager:
    def __init__(self):
        self._events = {}

    def register(self, event: str, callback):
        # 如果嘗試註冊的事件不在支援的事件列表中,直接丟擲異常
        if event not in EVENT_TYPE:
            raise RegisterEventException(f"Failed to register event `{event}`; event is not recognized.")

        # 同一事件不允許重複註冊
        if event in self._events:
            raise RegisterEventException(f"Failed to register event `{event}`; callback is already registered.")
        self._events[event] = callback

    async def run_callback(self, event, *args, **kwargs):
        # 若事件已註冊,執行事件的回撥函式並返回撥用結果
        # 這個函式被設計得更像一個通用、泛型的介面函式,能夠執行各種型別的回撥
        # 這樣的設計對於型別提示可能稍顯複雜
        callback = self._events.get(event)
        if callback:
            return await callback(*args, **kwargs)

這個簡潔而強大的事件管理器為框架提供了可擴充套件性和可自定義性,允許該框架使用者輕鬆註冊和執行各種事件的回撥函式。

不同 type 對應的 HandleClass

在 Application 的 __call__ 方法中,我會對不同的 scope type 進行不同的處理。結合上文的生命週期流程圖來看會更加清晰:

import functools
from typing import TYPE_CHECKING

from .logs import logger
from .response import Response, ErrorResponse, HTTPStatus
from .types import AsgiScope, AsgiReceive, AsgiSend, AsgiMessage
from .exceptions import NotFoundException, InvalidMethodException

if TYPE_CHECKING:
    from .application import Application

class AsgiLifespanHandle:
    def __init__(self, app: "Application"):
        self.app = app

    async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend):
        # 1. 對於生命週期處理,如應用程式啟動和關閉
        while True:
            message = await receive()
            if message["type"] == "lifespan.startup":
                asgi_message = await this._callback_fn_("startup")
                await send(asgi_message)
            elif message["type"] == "lifespan.shutdown":
                asgi_message = await this._callback_fn_("shutdown")
                await send(asgi_message)
                break

    async def _callback_fn_(self, event: str) -> AsgiMessage:
        # 2. 直接呼叫事件管理器的 run_callback 執行回撥函式
        try:
            await this.app.event_manager.run_callback(event)
        except Exception as exc:
            return {"type": f"lifespan.{event}.failed", "message": str(exc)}
        return {"type": f"lifespan.{event}.complete"}

class AsgiHttpHandle:
    def __init__(self, app: "Application"):
        self.app = app

    def _make_context(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend):
        # 2. 封裝上下文,類似於 Flask 封裝 request 和當前應用程式
        # 這使得以後可以直接從 razor.server 匯入 request 進行呼叫
        from .context import ContextManager
        return ContextManager(this.app, scope, receive, send)

    async def _run_handler(self, handle):
        # 4. 執行 before_request 的事件回撥函式
        await this.app.event_manager.run_callback("before_request")
        # 5. 執行檢視處理程式
        handle_response = await handle()
        # 6. 如果檢視處理程式的返回結果是 Response,則執行 after_request 的事件回撥函式
        if isinstance(handle_response, Response):
            callback_response = await this.app.event_manager.run_callback("after_request", handle_response)
            # 7. 如果 after_request 的回撥函式返回結果是 Response,則直接返回響應
            if isinstance(callback_response, Response):
                return callback_response
            # 8. 否則返回檢視處理程式的返回結果
            return handle_response

        # 9. 如果檢視處理程式的返回結果不是 Response,則返回一個 500 錯誤的響應物件
        logger.error(
            f"Invalid response type, expecting `{Response.__name__}` but getting `{type(handle_response).__name__}`")
        return ErrorResponse(status_code=HTTPStatus.INTERNAL_SERVER_ERROR)

    async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend):
        # 1. 處理 HTTP 請求
        ctx = this._make_context(scope, receive, send)
        ctx.push()
        path, method = scope["path"], scope["method"]

        try:
            # 3. 匹配路由
            match = this.app.router(path, method)
            match.target.path_params = match.params or {}
            # 可能的結果:
            #  - 檢視處理程式的響應
            #  - after_request 回撥的響應
            #  - 500 錯誤的 ErrorResponse
            response = await this._run_handler(functools.partial(match.target, **match.target.path_params))
        except NotFoundException as exc:
            # 路由未匹配到
            response = ErrorResponse(HTTPStatus.NOT_FOUND)
        except InvalidMethodException as exc:
            # 不支援的 HTTP 方法
            response = ErrorResponse(status_code=HTTPStatus.METHOD_NOT_ALLOWED)
        except Exception as exc:
            # 丟擲異常後執行異常的事件回撥函式
            logger.exception(exc)
            response = await this.app.event_manager.run_callback("exception", exc)
            # 如果異常事件回撥函式的結果型別不是 Response,則返回一個 500 錯誤的 ErrorResponse
            if not isinstance(response, Response):
                response = ErrorResponse(status_code=HTTPStatus.INTERNAL_SERVER_ERROR)
        finally:
            # 這是進行響應
            await response(scope, receive, send)
            # 清理上下文
            ctx.pop()

class AsgiWebsocketHandle:
    def __init__(self, app: "Application"):
        self.app = app

    async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend):
        pass

這些處理程式類為不同型別的 ASGI scope 提供了處理請求的方法,從應用程式生命週期事件管理到 HTTP 請求處理和 WebSocket 處理。

它們與事件管理器一起構成了一個完整的框架,為應用程式的核心功能提供了基本的支援。

關於 context 的處理

在這個我實現的框架中,對於 HTTP 請求的處理流程裡,第一步是封裝上下文。

具體的上下文封裝實現與 Flask 中封裝請求的過程非常相似,但更加簡潔。

import contextvars
from typing import Any, List, Type, TYPE_CHECKING

from .request import Request
from .types import AsgiScope, AsgiReceive, AsgiSend
from .globals import _cv_request, _cv_application

if TYPE_CHECKING:
    from .application import Application


class Context:
    def __init__(self, ctx_var: contextvars.ContextVar, var: Any) -> None:
        """
        初始化上下文物件

        Args:
            ctx_var (contextvars.ContextVar): 上下文變數
            var (Any): 儲存在上下文中的物件
        """
        self._var = var
        self._ctx_var = ctx_var
        self._cv_tokens: List[contextvars.Token] = []

    def push(self) -> None:
        """
        推入上下文物件到上下文棧
        """
        self._cv_tokens.append(
            self._ctx_var.set(self._var)
        )

    def pop(self) -> None:
        """
        從上下文棧中彈出上下文物件
        """
        if len(self._cv_tokens) == 1:
            token = self._cv_tokens.pop()
            self._ctx_var.reset(token)


class ApplicationContext(Context):
    def __init__(self, app):
        """
        初始化應用程式上下文物件

        Args:
            app: 應用程式例項
        """
        self._app = app
        super().__init__(
            ctx_var=_cv_application,
            var=self._app
        )


class RequestContext(Context):
    def __init__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend):
        """
        初始化請求上下文物件

        Args:
            scope (AsgiScope): ASGI作用域
            receive (AsgiReceive): 接收函式
            send (AsgiSend): 傳送函式
        """
        self._request = Request(scope, receive, send)
        super().__init__(
            ctx_var=_cv_request,
            var=self._request
        )


class ContextManager:
    def __init__(self, app: "Application", scope: AsgiReceive, receive: AsgiReceive, send: AsgiSend) -> None:
        """
        初始化上下文管理器

        Args:
            app (Application): 應用程式例項
            scope (AsgiReceive): ASGI作用域
            receive (AsgiReceive): 接收函式
            send (AsgiSend): 傳送函式
        """
        self._ctxs_: List[Type[Context]] = [
            ApplicationContext(app),
            RequestContext(scope, receive, send),
        ]

    def push(self) -> None:
        """
        將上下文物件推入上下文棧
        """
        for _ctx in self._ctxs_:
            _ctx.push()

    def pop(self) -> None:
        """
        從上下文棧中彈出上下文物件
        """
        for _ctx in self._ctxs_:
            _ctx.pop()

接下來是 globals 模組中的程式碼實現:

from contextvars import ContextVar

from .proxy import LocalProxy
from .request import Request
from .application import Application

# 上下文變數, 用於儲存當前的請求和應用例項物件
_cv_request: ContextVar = ContextVar("razor.request_context")
_cv_application: ContextVar = ContextVar("razor.application_context")

# 代理變數,用於被 import 匯入
request: Request = LocalProxy(_cv_request, Request)
current_application: Application = LocalProxy(_cv_application, Application)

核心的 proxy 程式碼實現基本和 Flask 一樣:

import operator
from functools import partial
from contextvars import ContextVar


class _ProxyLookup:
    def __init__(self, f):
        # `f`是`getattr`函式
        def bind_f(instance: "LocalProxy", obj):
            return partial(f, obj)
        self.bind_f = bind_f

    def __get__(self, instance: "LocalProxy", owner: Type | None = None):
        # `instance`是`LocalProxy`,呼叫其下的`_get_current_object`方法
        # 會得到上下文變數中存的具體物件,如`Request`例項物件,或者
        # `Application`例項物件
        # 然後會返回`bind_f`
        obj = instance._get_current_object()
        return self.bind_f(instance, obj)

    def __call__(self, instance: "LocalProxy", *args, **kwargs):
        # `bind_f`中由於給`getattr`繫結了偏函式, 所以
        # `getattr`的第一個引數始終是具體的上下文變數裡存的物件
        # 而`*args`中包含本次需要 . 訪問的方法名稱
        # 這樣就完成了代理的全步驟了
        return self.__get__(instance, type(instance))(*args, **kwargs)


class LocalProxy:
    def __init__(self, local, proxy):
        # `local`就是上下文變數`_cv_request`和`_cv_application`
        self.local = local
        # 這裡是代理的具體的類
        self.proxy = proxy

    def _get_current_object(self):
        if isinstance(self.local, ContextVar):
            return self.local.get()
        raise RuntimeError(f"不支援的上下文型別: {type(self.local)}")

    def __repr__(self) -> str:
        return f"<{self.__class__.__name__} {self.proxy.__module__}.{self.proxy.__name__}>"

    # 以後每次嘗試使用 . 方法訪問代理變數時,都將觸發
    # `_ProxyLookup`的`__call__`方法
    __getattr__ = _ProxyLookup(getattr)
    __getitem__ = _ProxyLookup(operator.__getitem__)

在這一部分,我們重點關注了上下文的處理。我的這個框架透過類似於 Flask 的方式,優雅而高效地封裝了請求和應用程式上下文。

透過 Context 類的建立和管理,我們能夠輕鬆地推入和彈出上下文,使請求和應用程式物件的訪問變得更加直觀和簡單。這種設計允許我們在不同部分的應用程式中輕鬆地獲取請求和應用程式物件,提高了程式碼的可讀性和可維護性。

而透過代理機制,我們建立了 LocalProxy 類,使得在整個應用程式中訪問上下文變數變得無縫和高效。這讓我們的程式碼更加模組化,有助於減少冗餘和提高程式碼質量。

總之,上下文的處理和代理機制的引入為框架的 HTTP 請求處理提供了強大而簡潔的基礎。

這將有助於提高框架的效能和可維護性,使開發者更容易建立出高質量的 Web 應用程式。

request 物件的封裝

request 在 RequestContext 中會首次例項化,關於其實現過程也較為簡單:

import json
from http.cookies import _unquote
from typing import Any, Union, Optional

# MultiDict 是一個特殊的字典
# 在 HTTP 請求中,會有一個 key 對應多個 value 的存在
# MultiDict 提供了 getall() 方法來獲取該 key 對應的所有 value
# PS: 這是 1 個第三方庫
from multidict import MultiDict

# DEFAULT_CODING 是 utf-8
# DEFAULT_CHARSET 是 latin-1
from .constants import DEFAULT_CODING, DEFAULT_CHARSET
from .forms import parse_form_data, SpooledTemporaryFile
from .types import AsgiScope, AsgiReceive, AsgiSend, AsgiMessage, JsonMapping


class Request:
    # Request 物件的屬性名使用 __slots__,以提高效能和節省記憶體
    __slots__ = (
        "scope",
        "receive",
        "send",
        "_headers",
        "_cookies",
        "_query",
        "_content",
        "_body",
        "_text",
        "_forms",
        "_files",
        "_json"
    )

    def __init__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend):
        # Request 主要使用 scope 中的資料和 receive 非同步函式來讀取請求體
        self.scope = scope
        self.receive = receive
        self.send = send

        # 懶載入屬性
        self._content: Optional[MultiDict[str]] = None
        self._headers: Optional[MultiDict[str]] = None
        self._cookies: Optional[MultiDict[str]] = None
        self._query: Optional[MultiDict[str]] = None
        self._body: Optional[bytes] = None
        self._text: Optional[str] = None
        self._json: Optional[JsonMapping] = None
        self._forms: Optional[MultiDict[str]] = None
        self._files: Optional[MultiDict[SpooledTemporaryFile]] = None

    def __getitem__(self, key: str) -> Any:
        # 使用 [] 語法可以直接獲取 scope 中的資源
        return self.scope[key]

    def __getattr__(self, key) -> Any:
        # 使用 . 語法可以直接獲取 scope 中的資源
        return self.scope[key]

    @property
    def content(self) -> MultiDict:
        # 解析 content
        # 其實 form 表單上傳的內容等等都在 content-type 請求頭裡
        # 所以我們這裡要在 content-type 請求頭中解析出
        # content-type  charset 以及 boundary( 儲存 form 表單資料)
        if not self._content:
            self._content = MultiDict()
            content_type, *parameters = self.headers.get("content-type", "").split(";", 1)
            for parameter in parameters:
                key, value = parameter.strip().split("=", 1)
                self._content.add(key, value)
            self._content.add("content-type", content_type)
            # 如果請求沒有攜帶 charset,則設定為預設的 CODING
            self._content.setdefault("charset", DEFAULT_CODING)
        return self._content

    @property
    def headers(self) -> MultiDict:
        # 解析請求頭, 將請求頭新增到 MultiDict 中
        if not self._headers:
            self._headers = MultiDict()
            for key, val in self.scope["headers"]:
                self._headers.add(key.decode(DEFAULT_CHARSET), val.decode(DEFAULT_CHARSET))
            self._headers["remote-addr"] = (self.scope.get("client") or ["<local>"])[0]
        return self._headers

    @property
    def cookies(self) -> MultiDict:
        # 解析 cookie
        if not self._cookies:
            self._cookies = MultiDict()
            for chunk in self.headers.get("cookie").split(";"):
                key, _, val = chunk.partition("=")
                if key and val:
                    # 這裡對於 val 必須做一次處理,使用了內建庫 http.cookie 中的 _unquote 函式
                    self._cookies[key.strip()] = _unquote(val.strip())
        return self._cookies

    @property
    def query(self) -> MultiDict:
        # 解析查詢引數
        if not self._query:
            self._query = MultiDict()
            for chunk in self.scope["query_string"].decode(DEFAULT_CHARSET).split("&"):
                key, _, val = chunk.partition("=")
                if key and val:
                    self._query.add(key.strip(), val.strip())
        return self._query

    async def body(self) -> bytes:
        # 讀取請求體
        if not self._body:
            self._body: bytes = b""
            while True:
                message: AsgiMessage = await self.receive()
                self._body += message.get("body", b"")
                # 這裡可以看具體的 ASGI 協議中關於 HTTP 的部分
                # 當 recv 的 message 中, more_body 如果為 False 則代表請求體讀取完畢了
                if not message.get("more_body"):
                    break
        return self._body

    async def text(self) -> str:
        # 嘗試使用請求的 charset 來解碼請求體
        if not self._text:
            body = await self.body()
            self._text = body.decode(self.content["charset"])
        return self._text

    async def form(self) -> MultiDict:
        # 讀取 form 表單中的資料
        if not self._forms:
            self._forms, self._files = await parse_form_data(self)
        return self._forms

    async def files(self) -> MultiDict[SpooledTemporaryFile]:
        # 讀取上傳的檔案資料
        if not self._files:
            self._forms, self._files = await parse_form_data(self)
        return self._files

    async def json(self) -> JsonMapping:
        # 嘗試反序列化出請求體
        if not self._json:
            text = await self.text()
            self._json = json.loads(text) if text else {}
        return self._json

    async def data(self) -> Union[MultiDict, JsonMapping, str]:
        # 透過 content-type 來快速獲取請求的內容
        content_type = self.content["content-type"]
        if content_type == "application/json":
            return await self.json()
        if content_type == "multipart/form-data":
            return await self.form()
        if content_type == "application/x-www-form-urlencoded":
            return await self.form()
        return self.text()

這一節我們深入研究了在框架中如何封裝請求物件。Request 物件在 RequestContext 中首次例項化,它提供了簡單而有效的方法來訪問HTTP請求的各個部分。

首先,Request 物件提供了屬性和方法,可以方便地訪問請求的內容,包括請求頭、cookie、查詢引數、請求體、文字、表單資料、檔案資料以及 JSON 資料。這些訪問方法都是懶載入的,只有在首次訪問時才會解析相應的內容,從而提高了效能和效率。

Request 物件的實現透過解析 HTTP 請求的各個部分,將它們以多值字典的形式儲存起來,使開發者可以輕鬆地獲取所需的資訊。這種封裝使得處理 HTTP 請求變得更加直觀和方便,降低了編寫應用程式的複雜性。

總的來說,Request 物件的封裝提供了強大的功能和簡潔的介面,使開發者能夠輕鬆地處理各種型別的 HTTP 請求,無需深入處理 HTTP 協議的細節。這為構建高效能和高效的 Web 應用程式提供了堅實的基礎。

關於 form 表單的解析

form 表單的解析比較麻煩,主要依賴於第三方庫 python-multipart 實現,在此程式碼中主要是如何呼叫 Parser 物件,以及其作用是什麼。

這裡不進行詳細講解,感興趣的朋友直接檢視該庫的文件即可,值得注意的地方已經在註釋中進行了標記:

import re
from io import BytesIO
from urllib.parse import unquote_to_bytes
from typing import Dict, Tuple, TYPE_CHECKING

if TYPE_CHECKING:
    from .request import Request

from multidict import MultiDict
from multipart.multipart import BaseParser, QuerystringParser, MultipartParser

# 需要注意,這裡的臨時檔案並非標準庫中的
from .datastructures import SpooledTemporaryFile

def unquote_plus(value: bytearray) -> bytes:
    value = value.replace(b"+", b" ")
    return unquote_to_bytes(bytes(value))

class FormDataReader:
    """
    用於讀取除 multipart/form-data 之外的編碼格式的資料
    比如 application/x-www-form-urlencoded 和其他編碼格式
    """
    __slots__ = ("forms", "curkey", "curval", "charset", "files")

    def __init__(self, charset: str):
        self.forms = MultiDict()
        self.files = MultiDict()
        self.curkey = bytearray()
        self.curval = bytearray()
        self.charset = charset

    def on_field_name(self, data: bytes, start: int, end: int):
        # curkey 實際上是最終的欄位名
        self.curkey += data[start:end]

    def on_field_data(self, data: bytes, start: int, end: int):
        # curval 實際上是最終的欄位值
        self.curval += data[start:end]

    def on_field_end(self, *_):
        # 將 bytearray 轉換為 str 並新增到 self.froms 中
        self.forms.add(
            unquote_plus(self.curkey).decode(self.charset),
            unquote_plus(self.curval).decode(self.charset),
        )
        self.curval.clear()
        self.curkey.clear()

    def get_parser(self, _: "Request") -> BaseParser:
        # 透過 multipart 模組提供的 QuerystringParser 對資料進行解析
        return QuerystringParser(
            {
                "on_field_name": self.on_field_name,
                "on_field_data": self.on_field_data,
                "on_field_end": self.on_field_end,
            },
        )

class MultipartReader:
    """
    用於讀取 multipart/form-data 編碼格式的資料
    可能包含上傳檔案等資訊
    """
    __slots__ = ("forms", "curkey", "curval", "charset", "filed_name", "headers", "filed_data", "files")

    def __init__(self, charset: str):
        self.forms = MultiDict()
        self.files = MultiDict()
        self.curkey = bytearray()
        self.curval = bytearray()
        self.charset = charset
        self.headers: Dict[bytes, bytes] = {}

        self.filed_name = ""
        self.filed_data = BytesIO()

    def get_parser(self, request: "Request") -> BaseParser:
        # 對於 multipart/form-data 型別的資料,我們需要從內容中獲取邊界資訊
        boundary = request.content.get("boundary", "")

        if not boundary:
            raise ValueError("Missing boundary")

        # 透過 multipart 模組提供的 MultipartParser 對資料進行解析
        return MultipartParser(
            boundary,
            {
                "on_header_field": self.on_header_field,
                "on_header_value": self.on_header_value,
                "on_header_end": self.on_header_end,
                "on_headers_finished": self.on_headers_finished,
                "on_part_data": self.on_part_data,
                "on_part_end": self.on_part_end,
            }
        )

    def on_header_field(self, data: bytes, start: int, end: int):
        # curkey 不是最終的欄位名
        self.curkey += data[start:end]

    def on_header_value(self, data: bytes, start: int, end: int):
        # curval 不是最終的欄位值
        self.curval += data[start:end]

    def on_header_end(self, *_):
        # 將 curkey 和 curval 新增到 self.headers 中
        self.headers[bytes(self.curkey.lower())] = bytes(self.curval)
        self.curkey.clear()
        self.curval.clear()

    def on_headers_finished(self, *_):
        _, options = parse_options_header(
            self.headers[b"content-disposition"].decode(self.charset),
        )

        self.filed_name = options["name"]

        # 如果能解析出 filename,則代表本次上傳的是檔案
        if "filename" in options:
            # 建立一個 SpooledTemporaryFile 以將檔案資料寫入記憶體
            self.filed_data = SpooledTemporaryFile()
            self.filed_data._file.name = options["filename"]
            self.filed_data.content_type = self.headers[b"content-type"].decode(self.charset)

    def on_part_data(self, data: bytes, start: int, end: int):
        # 將資料寫入 filed_data
        self.filed_data.write(data[start:end])

    def on_part_end(self, *_):
        field_data = self.filed_data

        # 如果是非檔案型別的資料,則將 field_name 和 field_value 新增到 self.forms 中
        if isinstance(field_data, BytesIO):
            self.forms.add(self.filed_name, field_data.getvalue().decode(self.charset))
        else:
            # 否則,將檔案型別的資料新增到 self.files 中
            field_data.seek(0)
            self.files.add(self.filed_name, self.filed_data)

        self.filed_data = BytesIO()
        self.headers = {}

OPTION_HEADER_PIECE_RE = re.compile(
    r"""
    \s*,?\s*  # 換行符被替換為逗號
    (?P<key>
        "[^"\\]*(?:\\.[^"\\]*)*"  # 帶引號的字串
    |
        [^\s;,=*]+  # 令牌
    )
    (?:\*(?P<count>\d+))?  # *1,可選的連續索引
    \s*
    (?:  # 可能後跟 =value
        (?:  # 等號,可能帶編碼
            \*\s*=\s*  # *表示擴充套件符號
            (?:  # 可選的編碼
                (?P<encoding>[^\s]+?)
                '(?P<language>[^\s]*?)'
            )?
        |
            =\s*  # 基本符號
        )
        (?P<value>
            "[^"\\]*(?:\\.[^"\\]*)*"  # 帶引號的字串
        |
            [^;,]+  # 令牌
        )?
    )?
    \s*;?
    """,
    flags=re.VERBOSE,
)

def parse_options_header(value: str) -> Tuple[str, Dict[str, str]]:
    """解析給定的內容描述頭。"""

    options: Dict[str, str] = {}
    if not value:
        return "", options

    if ";" not in value:
        return value, options

    ctype, rest = value.split(";", 1)
    while rest:
        match = OPTION_HEADER_PIECE_RE.match(rest)
        if not match:
            break

        option, count, encoding, _, value = match.groups()
        if value is not None:
            if encoding is not None:
                value = unquote_to_bytes(value).decode(encoding)

            if count:
                value = options.get(option, "") + value

        options[option] = value.strip('" ').replace("\\\\", "\\").replace('\\"', '"')
        rest = rest[match.end():]

    return ctype, options

async def parse_form_data(request: "Request") -> Tuple[MultiDict, MultiDict]:
    """
    由外部提供的函式,用於解析表單資料
    獲取 froms 表單資料以及檔案列表
    """
    charset = request.content["charset"]
    content_type = request.content["content-type"]

    # 1. 根據不同的 content-type 來確定使用哪個 Reader
    if content_type == "multipart/form-data":
        reader = MultipartReader(charset)
    else:
        reader = FormDataReader(charset)

    # 2. 獲取相應的解析器
    parser = reader.get_parser(request)

    # 3. 將資料寫入解析器
    parser.write(await request.body())
    parser.finalize()

    # 4. 返回 froms 和 files
    return reader.forms, reader.files

關於檔案的處理

在處理 form 表單時使用的 SpooledTemporaryFile 類實際上是經過了封裝的,並非直接由 Python 內建模組 tempfile 所提供。

封裝的 SpooledTemporaryFile 類位於 datastructures 檔案中,程式碼如下:

import os
from typing import Optional
from tempfile import SpooledTemporaryFile as BaseSpooledTemporaryFile

from aiofiles import open as async_open


class SpooledTemporaryFile(BaseSpooledTemporaryFile):
    async def save(self, destination: Optional[os.PathLike] = None):
        destination = destination or f"./{self.name}"
        dirname = os.path.dirname(destination)
        if not os.path.exists(dirname):
            os.makedirs(dirname, exist_ok=True)

        async with async_open(destination, mode="wb") as f:
            for line in self.readlines():
                await f.write(line)

如果讀者足夠細心,那麼也應該注意到了該框架在處理檔案時有一些不足之處。

它首先將使用者上傳的檔案全部寫入記憶體,然後在後端呼叫 file.save 方法時,才使用 aiofiles 模組非同步的將檔案寫入磁碟。

更好的方法應該是按需讀取檔案內容,只有在後端呼叫 file.save 方法時才將內容直接寫入磁碟。然而,由於 python-multipart 是同步庫,要實現這一點需要對原模組程式碼進行一些修改。

如果您對此感興趣,可以嘗試自行實現。

Router 的二次封裝

在完成 ctx 的構建後,路由匹配工作就開始了。透過 http-router 模組的強大功能,這一切變得非常簡單。不過,我對該模組提供的功能進行了二次封裝,以使使用更加便捷。

在此之前,讀者可以檢視該庫的 README 以瞭解該庫的基本用法:

import inspect
from typing import Optional, ClassVar, Type, Tuple, Callable, TYPE_CHECKING

from http_router import Router as HttpRouter

from .exceptions import RouterException, NotFoundException, InvalidMethodException
from .views import View

if TYPE_CHECKING:
    from http_router.types import TVObj, TPath, TMethodsArg

class Router(HttpRouter):
    # 首先,我們採用自定義的異常類來覆蓋內建異常,以便更加方便地匯入
    RouterError: ClassVar[Type[Exception]] = RouterException
    NotFoundError: ClassVar[Type[Exception]] = NotFoundException
    InvalidMethodError: ClassVar[Type[Exception]] = InvalidMethodException

    def route(
        self,
        *paths: "TPath",
        methods: Optional["TMethodsArg"] = None,
        **opts,
    ) -> Callable[["TVObj"], "TVObj"]:
        def wrapper(target: "TVObj") -> "TVObj":
            nonlocal methods

            # 如果 view handle 是一個類並且是 View 的子類,則
            # 呼叫 View 的 as_view() 方法,這一點和 Django 內部保持一致
            if inspect.isclass(target) and issubclass(target, View):
                target = target.as_view()

            # 否則,表示採用了 FBV 模式,預設 http-router 會新增所有的請求方法
            # 但在這裡,我希望只預設新增 GET 請求,因此進行了一些修改
            else:
                methods = methods or ["GET"]
                # 新增 OPTIONS 請求主要是為了處理 CORS 的情況
                if "OPTIONS" not in methods:
                    methods.append("OPTIONS")

            if hasattr(target, "__route__"):
                target.__route__(self, *paths, methods=methods, **opts)
                return target

            if not self.validator(target):
                raise self.RouterError("Invalid target: %r" % target)

            target = self.converter(target)
            self.bind(target, *paths, methods=methods, **opts)
            return target

        return wrapper

    def add_routes(self, *routes):
        # 提供類似於 Django 的 urlpatterns 的功能
        # 但在這裡,對於 FBV 的處理方式是呼叫了 super().route() 而非 self.route()
        # 即預設允許所有的請求方法
        # 同時,這也意味著對於 CBV 必須手動呼叫 cls.as_view()
        for route_rule in routes:
            *paths, handle = route_rule
            super().route(*paths)(handle)

總結一下,透過對 http-router 模組的二次封裝,我實現了更便捷的路由匹配方法。

這個封裝還提供了更友好的異常處理,並支援 FBV 和 CBV 模式的統一處理。

response 物件的封裝

下面是有關 Response 物件的封裝,實現 Response 時,我曾考慮是否按照 FastAPI 的風格來設計。

例如,直接返回一個字典將自動轉換為 JSON 格式。

然而,我最終決定不這樣做。儘管這可能會增加編寫程式碼時的效率,但同樣也會增加維護程式碼時的可讀性和複雜度。

因此,我決定必須顯式地呼叫某個 Response,否則本次請求將返回 500 響應碼。

import json
from http import HTTPStatus
from http.cookies import SimpleCookie
from typing import Optional
from urllib.parse import quote_plus

from multidict import MultiDict
from markupsafe import escape  # markupsafe 是一個第三方庫,用於防止跨站指令碼攻擊(XSS)

from .types import AsgiScope, AsgiReceive, AsgiSend
from .constants import DEFAULT_CODING, DEFAULT_CHARSET

class Response:
    status_code: int = HTTPStatus.OK.value  # 預設響應碼為 200
    content_type: Optional[str] = None

    def __init__(self, content, *, status_code=200, content_type=None, headers=None, cookies=None) -> None:
        self.status_code = status_code
        self.cookies = SimpleCookie(cookies)
        self.headers = MultiDict(headers or {})
        self.content = self.handle_content(content)

        # 處理響應的 content-type 和 charset
        content_type = content_type or self.content_type
        if content_type:
            if content_type.startswith("text/"):
                content_type = "{}; charset={}".format(content_type, DEFAULT_CODING)

            self.headers.setdefault("content-type", content_type)

    def handle_content(self, content):
        # 處理響應體的內容,進行編碼操作
        if not isinstance(content, bytes):
            return str(content).encode(DEFAULT_CODING)

        return content

    async def __call__(self, scope: AsgiScope, receive: AsgiReceive, send: AsgiSend) -> None:
        # 這裡在不同型別的 HandleClass 中會對 response 例項物件進行 await 操作,
        # 實際上也是會呼叫此 __call__ 方法

        # 處理響應頭
        self.headers.setdefault("content-length", str(len(self.content))

        headers = [
            (key.encode(DEFAULT_CHARSET), str(val).encode(DEFAULT_CHARSET))
            for key, val in self.headers.items()
        ]

        for cookie in self.cookies.values():
            headers = [
                *headers,
                (b"set-cookie", cookie.output(header="").strip().encode(DEFAULT_CHARSET)),
            ]

        # 傳送響應頭和響應狀態
        await send({
            "type": "http.response.start",
            "status": self.status_code,
            "headers": headers,
        })

        # 傳送響應體
        await send({"type": "http.response.body", "body": self.content})

class TextResponse(Response):
    content_type = "text/plain"

class HtmlResponse(Response):
    content_type = "text/html"

class JsonResponse(Response):
    content_type = "application/json"

    # 重寫 handle_content 以進行序列化和編碼操作
    def handle_content(self, content):
        return json.dumps(content, ensure_ascii=False).encode("utf-8")

class RedirectResponse(Response):
    status_code: int = HTTPStatus.TEMPORARY_REDIRECT.value  # 重定向預設是 307 的響應碼

    def __init__(self, url, status_code: Optional[int] = None, **kwargs) -> None:
        self.status_code = status_code or self.status_code
        super().__init(
            content=b"",
            status_code=self.status_code,
            **kwargs
        )
        assert 300 <= self.status_code < 400, f"無效的重定向狀態碼: {self.status_code}"
        self.headers["location"] = quote_plus(url, safe=":/%#?&=@[]!$&'()*+,;")

class ErrorResponse(Response):
    content_type = "text/html"

    def __init__(self, status_code: int, content=None, **kwargs):
        if status_code < 400:
            raise ValueError("響應碼 < 400")

        _o = HTTPStatus(status_code)

        # 如果傳入了 content,則使用傳遞進來的 content 作為響應體
        # 否則使用內建庫 HTTPStatus 的狀態碼描述資訊等返回一個預設的錯誤頁面
        content = content or self.get_err_page(_o.value, _o.phrase, _o.description)

        super().__init(
            content=content,
            status_code=status_code,
            **kwargs
        )

    def get_err_page(self, code, name, descript):
        # 受到 Werkzeug 模組的啟發
        # 這裡需要使用 markupsafe 中的 escape 進行防止跨站指令碼攻擊(XSS)的操作,最後返回響應的頁面
        return (
            "<!doctype html>\n"
            "<html lang=en>\n"
            f"<title>{code} {escape(name)}</title>\n"
            f"<h1>{escape(name)}</h1>\n"
            f"<p>{escape(descript)}</p>\n"
        )

透過對 Response 物件的封裝,該框架提供了更加靈活和可控的方式來構建和處理響應。這允許開發者明確地定義響應的狀態碼、內容型別、頭部資訊、Cookies 等。

Class-Base-View 的實現

最後一節涉及到了 Class-Base-View 的實現,其實現方式非常簡單,參考了 Django 中對 Class-Base-View 的處理方式。

class View:
    def __init__(self, **kwargs):
        for key, value in kwargs.items():
            setattr(self, key, value)

    @classmethod
    def as_view(cls, **initkwargs):
        # 1. 由 as_view 來例項化物件, 並且透過 dispatch 方法找到和本次請求方式一致的方法名稱
        async def view(*args, **kwargs):
            self = cls(**initkwargs)
            self.setup(*args, **kwargs)
            return await self.dispatch(*args, **kwargs)

        view.view_class = cls
        view.view_initkwargs = initkwargs

        view.__doc__ = cls.__doc__
        view.__module__ = cls.__module__
        view.__annotations__ = cls.dispatch.__annotations__
        view.__dict__.update(cls.dispatch.__dict__)

        return view

    async def dispatch(self, *args, **kwargs):

        from .globals import request
        from .response import ErrorResponse, HTTPStatus

        if hasattr(self, request.method.lower()):
            handler = getattr(self, request.method.lower())
            return await handler(*args, **kwargs)

        # 2. 若派生類沒有實現所請求的方法,則直接返回 code 為 405 的 ErrorResponse
        return ErrorResponse(HTTPStatus.METHOD_NOT_ALLOWED)

    def setup(self, *args, **kwargs):
        if hasattr(self, "get") and not hasattr(self, "head"):
            self.head = self.get

透過 Class-Base-View 的實現,開發者可以更輕鬆地構建基於類的檢視,與請求方法一一對應,並實現相應的處理方法。

這種模式可以幫助開發者更好地組織和管理檢視邏輯。

結語

這篇文章在此也應當告一段落了,透過這次從零編寫一個基於 ASGI 的 WEB 框架,讓我感到非常的滿足和開心,其一是對於獲得新知識的喜悅之情,其二是對於自我成就的滿足之感。

但這僅僅是一個開始而已,無論如何我們應當保持充足的熱情迎接各種新的事物。

這份自我實現的 WEB 框架我並未上架 pypi,它只是我學習過程中的一個半成品(儘管它可以滿足了一些基礎業務的需求),並未真正優秀到我會推薦給別人使用。

此外它目前也不支援 WebSocket,若後續有時間和想法對 WebSocket 的 Handle-Class 部分進行了實現,再與諸君一同分享。

最後貼一個該專案 GitHub 地址,希望能對閱讀這篇文章的你帶來一些正向的影響。

askfiy/razor

相關文章