從零構建一個簡單的 Python 框架

Matt發表於2016-09-13

為什麼你想要自己構建一個 web 框架呢?我想,原因有以下幾點:

  • 你有一個新奇的想法,覺得將會取代其他的框架
  • 你想要獲得一些名氣
  • 你遇到的問題很獨特,以至於現有的框架不太合適
  • 你對 web 框架是如何工作的很感興趣,因為你想要成為一位更好的 web 開發者。

接下來的筆墨將著重於最後一點。這篇文章旨在透過對設計和實現過程一步一步的闡述告訴讀者,我在完成一個小型的伺服器和框架之後學到了什麼。你可以在這個程式碼倉庫中找到這個專案的完整程式碼。

我希望這篇文章可以鼓勵更多的人來嘗試,因為這確實很有趣。它讓我知道了 web 應用是如何工作的,而且這比我想的要容易的多!

範圍

框架可以處理請求-響應週期、身份認證、資料庫訪問、模板生成等部分工作。Web 開發者使用框架是因為,大多數的 web 應用擁有大量相同的功能,而對每個專案都重新實現同樣的功能意義不大。

比較大的的框架如 Rails 和 Django 實現了高層次的抽象,或者說“自備電池”(“batteries-included”,這是 Python 的口號之一,意即所有功能都自足。)。而實現所有的這些功能可能要花費數千小時,因此在這個專案上,我們重點完成其中的一小部分。在開始寫程式碼前,我先列舉一下所需的功能以及限制。

功能:

  • 處理 HTTP 的 GET 和 POST 請求。你可以在這篇 wiki 中對 HTTP 有個大致的瞭解。
  • 實現非同步操作(我喜歡 Python 3 的 asyncio 模組)。
  • 簡單的路由邏輯以及引數擷取。
  • 像其他微型框架一樣,提供一個簡單的使用者級 API 。
  • 支援身份認證,因為學會這個很酷啊(微笑)。

限制:

  • 將只支援 HTTP 1.1 的一個小子集,不支援傳輸編碼transfer-encodingHTTP 認證http-auth內容編碼content-encoding(如 gzip)以及持久化連線等功能。
  • 不支援對響應內容的 MIME 判斷 - 使用者需要手動指定。
  • 不支援 WSGI - 僅能處理簡單的 TCP 連線。
  • 不支援資料庫。

我覺得一個小的用例可以讓上述內容更加具體,也可以用來演示這個框架的 API:

from diy_framework import App, Router
from diy_framework.http_utils import Response


# GET simple route
async def home(r):
    rsp = Response()
    rsp.set_header('Content-Type', 'text/html')
    rsp.body = '<html><body><b>test</b></body></html>'
    return rsp


# GET route + params
async def welcome(r, name):
    return "Welcome {}".format(name)

# POST route + body param
async def parse_form(r):
    if r.method == 'GET':
        return 'form'
    else:
        name = r.body.get('name', '')[0]
        password = r.body.get('password', '')[0]

       return "{0}:{1}".format(name, password)

# application = router + http server
router = Router()
router.add_routes({
    r'/welcome/{name}': welcome,
    r'/': home,
    r'/login': parse_form,})

app = App(router)
app.start_server()

' 使用者需要定義一些能夠返回字串或 Response 物件的非同步函式,然後將這些函式與表示路由的字串配對,最後透過一個函式呼叫(start_server)開始處理請求。

完成設計之後,我將它抽象為幾個我需要編碼的部分:

  • 接受 TCP 連線以及排程一個非同步函式來處理這些連線的部分
  • 將原始文字解析成某種抽象容器的部分
  • 對於每個請求,用來決定呼叫哪個函式的部分
  • 將上述部分集中到一起,併為開發者提供一個簡單介面的部分

我先編寫一些測試,這些測試被用來描述每個部分的功能。幾次重構後,整個設計被分成若干部分,每個部分之間是相對解耦的。這樣就非常好,因為每個部分可以被獨立地研究學習。以下是我上文列出的抽象的具體體現:

  • 一個 HTTPServer 物件,需要一個 Router 物件和一個 http_parser 模組,並使用它們來初始化。
  • HTTPConnection 物件,每一個物件表示一個單獨的客戶端 HTTP 連線,並且處理其請求-響應週期:使用 http_parser 模組將收到的位元組流解析為一個 Request 物件;使用一個 Router 例項尋找並呼叫正確的函式來生成一個響應;最後將這個響應傳送回客戶端。
  • 一對 Request 和 Response 物件為使用者提供了一種友好的方式,來處理實質上是位元組流的字串。使用者不需要知道正確的訊息格式和分隔符是怎樣的。
  • 一個包含“路由:函式”對應關係的 Router 物件。它提供一個新增配對的方法,可以根據 URL 路徑查詢到相應的函式。
  • 最後,一個 App 物件。它包含配置資訊,並使用它們例項化一個 HTTPServer 例項。

讓我們從 HTTPConnection 開始來講解各個部分。

模擬非同步連線

為了滿足上述約束條件,每一個 HTTP 請求都是一個單獨的 TCP 連線。這使得處理請求的速度變慢了,因為建立多個 TCP 連線需要相對高的花銷(DNS 查詢,TCP 三次握手,慢啟動等等的花銷),不過這樣更加容易模擬。對於這一任務,我選擇相對高階的 asyncio-stream 模組,它建立在 asyncio 的傳輸和協議的基礎之上。我強烈推薦你讀一讀標準庫中的相應程式碼,很有意思!

一個 HTTPConnection 的例項能夠處理多個任務。首先,它使用 asyncio.StreamReader 物件以增量的方式從 TCP 連線中讀取資料,並儲存在快取中。每一個讀取操作完成後,它會嘗試解析快取中的資料,並生成一個 Request 物件。一旦收到了這個完整的請求,它就生成一個回覆,並透過 asyncio.StreamWriter 物件傳送回客戶端。當然,它還有兩個任務:超時連線以及錯誤處理。

你可以在這裡瀏覽這個類的完整程式碼。我將分別介紹程式碼的每一部分。為了簡單起見,我移除了程式碼文件。

class HTTPConnection(object):
    def init(self, http_server, reader, writer):
        self.router = http_server.router
        self.http_parser = http_server.http_parser
        self.loop = http_server.loop

        self._reader = reader
        self._writer = writer
        self._buffer = bytearray()
        self._conn_timeout = None
        self.request = Request()

這個 init 方法沒啥意思,它僅僅是收集了一些物件以供後面使用。它儲存了一個 router 物件、一個 http_parser 物件以及 loop 物件,分別用來生成響應、解析請求以及在事件迴圈中排程任務。

然後,它儲存了代表一個 TCP 連線的讀寫對,和一個充當原始位元組緩衝區的空位元組陣列_conn_timeout 儲存了一個 asyncio.Handle 的例項,用來管理超時邏輯。最後,它還儲存了 Request 物件的一個單一例項。

下面的程式碼是用來接受和傳送資料的核心功能:

async def handle_request(self):
    try:
        while not self.request.finished and not self._reader.at_eof():
            data = await self._reader.read(1024)
            if data:
                self._reset_conn_timeout()
                await self.process_data(data)
        if self.request.finished:
            await self.reply()
        elif self._reader.at_eof():
            raise BadRequestException()
    except (NotFoundException,
            BadRequestException) as e:
        self.error_reply(e.code, body=Response.reason_phrases[e.code])
    except Exception as e:
        self.error_reply(500, body=Response.reason_phrases[500])

    self.close_connection()

所有內容被包含在 try-except 程式碼塊中,這樣在解析請求或響應期間丟擲的異常可以被捕獲到,然後一個錯誤響應會傳送回客戶端。

while 迴圈中不斷讀取請求,直到解析器將 self.request.finished 設定為 True ,或者客戶端關閉連線所觸發的訊號使得 self._reader_at_eof() 函式返回值為 True 為止。這段程式碼嘗試在每次迴圈迭代中從 StreamReader 中讀取資料,並透過呼叫 self.process_data(data) 函式以增量方式生成 self.request。每次迴圈讀取資料時,連線超時計數器被重置。

這兒有個錯誤,你發現了嗎?稍後我們會再討論這個。需要注意的是,這個迴圈可能會耗盡 CPU 資源,因為如果沒有讀取到東西 self._reader.read() 函式將會返回一個空的位元組物件 b''。這就意味著迴圈將會不斷執行,卻什麼也不做。一個可能的解決方法是,用非阻塞的方式等待一小段時間:await asyncio.sleep(0.1)。我們暫且不對它做最佳化。

還記得上一段我提到的那個錯誤嗎?只有從 StreamReader 讀取資料時,self._reset_conn_timeout() 函式才會被呼叫。這就意味著,直到第一個位元組到達時timeout 才被初始化。如果有一個客戶端建立了與伺服器的連線卻不傳送任何資料,那就永遠不會超時。這可能被用來消耗系統資源,從而導致拒絕服務式攻擊(DoS)。修復方法就是在 init 函式中呼叫 self._reset_conn_timeout() 函式。

當請求接受完成或連線中斷時,程式將執行到 if-else 程式碼塊。這部分程式碼會判斷解析器收到完整的資料後是否完成了解析。如果是,好,生成一個回覆併傳送回客戶端。如果不是,那麼請求資訊可能有錯誤,丟擲一個異常!最後,我們呼叫 self.close_connection 執行清理工作。

解析請求的部分在 self.process_data 方法中。這個方法非常簡短,也易於測試:

async def process_data(self, data):
    self._buffer.extend(data)

    self._buffer = self.http_parser.parse_into(
        self.request, self._buffer)

每一次呼叫都將資料累積到 self._buffer 中,然後試著用 self.http_parser 來解析已經收集的資料。這裡需要指出的是,這段程式碼展示了一種稱為依賴注入(Dependency Injection)的模式。如果你還記得 init 函式的話,應該知道我們傳入了一個包含 http_parser 物件的 http_server 物件。在這個例子裡,http_parser 物件是 diy_framework 包中的一個模組。不過它也可以是任何含有 parse_into 函式的類,這個 parse_into 函式接受一個 Request 物件以及位元組陣列作為引數。這很有用,原因有二:一是,這意味著這段程式碼更易擴充套件。如果有人想透過一個不同的解析器來使用 HTTPConnection,沒問題,只需將它作為引數傳入即可。二是,這使得測試更加容易,因為 http_parser 不是硬編碼的,所以使用虛假資料或者 mock 物件來替代是很容易的。

下一段有趣的部分就是 reply 方法了:

async def reply(self):
    request = self.request
    handler = self.router.get_handler(request.path)

    response = await handler.handle(request)

    if not isinstance(response, Response):
        response = Response(code=200, body=response)

    self._writer.write(response.to_bytes())
    await self._writer.drain()

這裡,一個 HTTPConnection 的例項使用了 HTTPServer 中的 router 物件來得到一個生成響應的物件。一個路由可以是任何一個擁有 get_handler 方法的物件,這個方法接收一個字串作為引數,返回一個可呼叫的物件或者丟擲 NotFoundException 異常。而這個可呼叫的物件被用來處理請求以及生成響應。處理程式由框架的使用者編寫,如上文所說的那樣,應該返回字串或者 Response 物件。Response 物件提供了一個友好的介面,因此這個簡單的 if 語句保證了無論處理程式返回什麼,程式碼最終都得到一個統一的 Response 物件。

接下來,被賦值給 self._writerStreamWriter 例項被呼叫,將位元組字串傳送回客戶端。函式返回前,程式在 await self._writer.drain() 處等待,以確保所有的資料被髮送給客戶端。只要快取中還有未傳送的資料,self._writer.close() 方法就不會執行。

HTTPConnection 類還有兩個更加有趣的部分:一個用於關閉連線的方法,以及一組用來處理超時機制的方法。首先,關閉一條連線由下面這個小函式完成:

def close_connection(self):
    self._cancel_conn_timeout()
    self._writer.close()

每當一條連線將被關閉時,這段程式碼首先取消超時,然後把連線從事件迴圈中清除。

超時機制由三個相關的函式組成:第一個函式在超時後給客戶端傳送錯誤訊息並關閉連線;第二個函式用於取消當前的超時;第三個函式排程超時功能。前兩個函式比較簡單,我將詳細解釋第三個函式 _reset_cpmm_timeout()

def _conn_timeout_close(self):
    self.error_reply(500, 'timeout')
    self.close_connection()

def _cancel_conn_timeout(self):
    if self._conn_timeout:
        self._conn_timeout.cancel()

def _reset_conn_timeout(self, timeout=TIMEOUT):
    self._cancel_conn_timeout()
    self._conn_timeout = self.loop.call_later(
        timeout, self._conn_timeout_close)

每當 _reset_conn_timeout 函式被呼叫時,它會先取消之前所有賦值給 self._conn_timeoutasyncio.Handle 物件。然後,使用 BaseEventLoop.call_later 函式讓 _conn_timeout_close 函式在超時數秒(timeout)後執行。如果你還記得 handle_request 函式的內容,就知道每當接收到資料時,這個函式就會被呼叫。這就取消了當前的超時並且重新安排 _conn_timeout_close 函式在超時數秒(timeout)後執行。只要接收到資料,這個迴圈就會不斷地重置超時回撥。如果在超時時間內沒有接收到資料,最後函式 _conn_timeout_close 就會被呼叫。

建立連線

我們需要建立 HTTPConnection 物件,並且正確地使用它們。這一任務由 HTTPServer 類完成。HTTPServer 類是一個簡單的容器,可以儲存著一些配置資訊(解析器,路由和事件迴圈例項),並使用這些配置來建立 HTTPConnection 例項:

class HTTPServer(object):
    def init(self, router, http_parser, loop):
        self.router = router
        self.http_parser = http_parser
        self.loop = loop

    async def handle_connection(self, reader, writer):
        connection = HTTPConnection(self, reader, writer)
        asyncio.ensure_future(connection.handle_request(), loop=self.loop)

HTTPServer 的每一個例項能夠監聽一個埠。它有一個 handle_connection 的非同步方法來建立 HTTPConnection 的例項,並安排它們在事件迴圈中執行。這個方法被傳遞給 asyncio.start_server 作為一個回撥函式。也就是說,每當一個 TCP 連線初始化時(以 StreamReaderStreamWriter 為引數),它就會被呼叫。

   self._server = HTTPServer(self.router, self.http_parser, self.loop)
   self._connection_handler = asyncio.start_server(
        self._server.handle_connection,
        host=self.host,
        port=self.port,
        reuse_address=True,
        reuse_port=True,
        loop=self.loop)

這就是構成整個應用程式工作原理的核心:asyncio.start_server 接受 TCP 連線,然後在一個預配置的 HTTPServer 物件上呼叫一個方法。這個方法將處理一條 TCP 連線的所有邏輯:讀取、解析、生成響應併傳送回客戶端、以及關閉連線。它的重點是 IO 邏輯、解析和生成響應。

講解了核心的 IO 部分,讓我們繼續。

解析請求

這個微型框架的使用者被寵壞了,不願意和位元組打交道。它們想要一個更高層次的抽象 —— 一種更加簡單的方法來處理請求。這個微型框架就包含了一個簡單的 HTTP 解析器,能夠將位元組流轉化為 Request 物件。

這些 Request 物件是像這樣的容器:

class Request(object):
    def init(self):
        self.method = None
        self.path = None
        self.query_params = {}
        self.path_params = {}
        self.headers = {}
        self.body = None
        self.body_raw = None
        self.finished = False

它包含了所有需要的資料,可以用一種容易理解的方法從客戶端接受資料。哦,不包括 cookie ,它對身份認證是非常重要的,我會將它留在第二部分。

每一個 HTTP 請求都包含了一些必需的內容,如請求路徑和請求方法。它們也包含了一些可選的內容,如請求體、請求頭,或是 URL 引數。隨著 REST 的流行,除了 URL 引數,URL 本身會包含一些資訊。比如,"/user/1/edit" 包含了使用者的 id 。

一個請求的每個部分都必須被識別、解析,並正確地賦值給 Request 物件的對應屬性。HTTP/1.1 是一個文字協議,事實上這簡化了很多東西。(HTTP/2 是一個二進位制協議,這又是另一種樂趣了)

解析器不需要跟蹤狀態,因此 http_parser 模組其實就是一組函式。呼叫函式需要用到 Request 物件,並將它連同一個包含原始請求資訊的位元組陣列傳遞給 parse_into 函式。然後解析器會修改 Request 物件以及充當快取的位元組陣列。位元組陣列的資訊被逐漸地解析到 request 物件中。

http_parser 模組的核心功能就是下面這個 parse_into 函式:

def parse_into(request, buffer):
    _buffer = buffer[:]
    if not request.method and can_parse_request_line(_buffer):
        (request.method, request.path,
         request.query_params) = parse_request_line(_buffer)
        remove_request_line(_buffer)

    if not request.headers and can_parse_headers(_buffer):
        request.headers = parse_headers(_buffer)
        if not has_body(request.headers):
            request.finished = True

        remove_intro(_buffer)

    if not request.finished and can_parse_body(request.headers, _buffer):
        request.body_raw, request.body = parse_body(request.headers, _buffer)
        clear_buffer(_buffer)
        request.finished = True
    return _buffer

從上面的程式碼中可以看到,我把解析的過程分為三個部分:解析請求行(這行像這樣:GET /resource HTTP/1.1),解析請求頭以及解析請求體。

請求行包含了 HTTP 請求方法以及 URL 地址。而 URL 地址則包含了更多的資訊:路徑、url 引數和開發者自定義的 url 引數。解析請求方法和 URL 還是很容易的 - 合適地分割字串就好了。函式 urlparse.parse 可以用來解析 URL 引數。開發者自定義的 URL 引數可以透過正規表示式來解析。

接下來是 HTTP 頭部。它們是一行行由鍵值對組成的簡單文字。問題在於,可能有多個 HTTP 頭有相同的名字,卻有不同的值。一個值得關注的 HTTP 頭部是 Content-Length,它描述了請求體的位元組長度(不是整個請求,僅僅是請求體)。這對於決定是否解析請求體有很重要的作用。

最後,解析器根據 HTTP 方法和頭部來決定是否解析請求體。

路由!

在某種意義上,路由就像是連線框架和使用者的橋樑,使用者用合適的方法建立 Router 物件併為其設定路徑/函式對,然後將它賦值給 App 物件。而 App 物件依次呼叫 get_handler 函式生成相應的回撥函式。簡單來說,路由就負責兩件事,一是儲存路徑/函式對,二是返回需要的路徑/函式對

Router 類中有兩個允許最終開發者新增路由的方法,分別是 add_routesadd_route。因為 add_routes 就是 add_route 函式的一層封裝,我們將主要講解 add_route 函式:

def add_route(self, path, handler):
    compiled_route = self.class.build_route_regexp(path)
    if compiled_route not in self.routes:
        self.routes[compiled_route] = handler
    else:
        raise DuplicateRoute

首先,這個函式使用 Router.build_router_regexp 的類方法,將一條路由規則(如 '/cars/{id}' 這樣的字串),“編譯”到一個已編譯的正規表示式物件。這些已編譯的正規表示式用來匹配請求路徑,以及解析開發者自定義的 URL 引數。如果已經存在一個相同的路由,程式就會丟擲一個異常。最後,這個路由/處理程式對被新增到一個簡單的字典self.routes中。

下面展示 Router 是如何“編譯”路由的:

@classmethod
def build_route_regexp(cls, regexp_str):
    """
    Turns a string into a compiled regular expression. Parses '{}' into
    named groups ie. '/path/{variable}' is turned into
    '/path/(?P<variable>[a-zA-Z0-9_-]+)'.

    :param regexp_str: a string representing a URL path.
    :return: a compiled regular expression.
    """
    def named_groups(matchobj):
        return '(?P<{0}>[a-zA-Z0-9_-]+)'.format(matchobj.group(1))

    re_str = re.sub(r'{([a-zA-Z0-9_-]+)}', named_groups, regexp_str)
    re_str = ''.join(('^', re_str, '$',))
    return re.compile(re_str)

這個方法使用正規表示式將所有出現的 {variable} 替換為 (?P<variable>)。然後在字串頭尾分別新增 ^$ 標記,最後編譯正規表示式物件。

完成了路由儲存僅成功了一半,下面是如何得到路由對應的函式:

def get_handler(self, path):
    logger.debug('Getting handler for: {0}'.format(path))
    for route, handler in self.routes.items():
        path_params = self.class.match_path(route, path)
        if path_params is not None:
            logger.debug('Got handler for: {0}'.format(path))
            wrapped_handler = HandlerWrapper(handler, path_params)
            return wrapped_handler

    raise NotFoundException()

一旦 App 物件獲得一個 Request 物件,也就獲得了 URL 的路徑部分(如 /users/15/edit)。然後,我們需要匹配函式來生成一個響應或者 404 錯誤。get_handler 函式將路徑作為引數,迴圈遍歷路由,對每條路由呼叫 Router.match_path 類方法檢查是否有已編譯的正則物件與這個請求路徑匹配。如果存在,我們就呼叫 HandleWrapper 來包裝路由對應的函式。path_params 字典包含了路徑變數(如 '/users/15/edit' 中的 '15'),若路由沒有指定變數,字典就為空。最後,我們將包裝好的函式返回給 App 物件。

如果遍歷了所有的路由都找不到與路徑匹配的,函式就會丟擲 NotFoundException 異常。

這個 Route.match 類方法挺簡單:

def match_path(cls, route, path):
    match = route.match(path)
    try:
        return match.groupdict()
    except AttributeError:
        return None

它使用正則物件的 match 方法來檢查路由是否與路徑匹配。若果不匹配,則返回 None 。

最後,我們有 HandleWraapper 類。它的唯一任務就是封裝一個非同步函式,儲存 path_params 字典,並透過 handle 方法對外提供一個統一的介面。

class HandlerWrapper(object):
    def init(self, handler, path_params):
        self.handler = handler
        self.path_params = path_params
        self.request = None

    async def handle(self, request):
        return await self.handler(request, **self.path_params)

組合到一起

框架的最後部分就是用 App 類把所有的部分聯絡起來。

App 類用於集中所有的配置細節。一個 App 物件透過其 start_server 方法,使用一些配置資料建立一個 HTTPServer 的例項,然後將它傳遞給 asyncio.start_server 函式asyncio.start_server 函式會對每一個 TCP 連線呼叫 HTTPServer 物件的 handle_connection 方法。

def start_server(self):
    if not self._server:
        self.loop = asyncio.get_event_loop()
        self._server = HTTPServer(self.router, self.http_parser, self.loop)
        self._connection_handler = asyncio.start_server(
            self._server.handle_connection,
            host=self.host,
            port=self.port,
            reuse_address=True,
            reuse_port=True,
            loop=self.loop)

        logger.info('Starting server on {0}:{1}'.format(
            self.host, self.port))
        self.loop.run_until_complete(self._connection_handler)

        try:
            self.loop.run_forever()
        except KeyboardInterrupt:
            logger.info('Got signal, killing server')
        except DiyFrameworkException as e:
            logger.error('Critical framework failure:')
            logger.error(e.traceback)
        finally:
            self.loop.close()
    else:
        logger.info('Server already started - {0}'.format(self))

總結

如果你檢視原始碼,就會發現所有的程式碼僅 320 餘行(包括測試程式碼的話共 540 餘行)。這麼少的程式碼實現了這麼多的功能,讓我有點驚訝。這個框架沒有提供模板、身份認證以及資料庫訪問等功能(這些內容也很有趣哦)。這也讓我知道,像 Django 和 Tornado 這樣的框架是如何工作的,而且我能夠快速地除錯它們了。

這也是我按照測試驅動開發完成的第一個專案,整個過程有趣而有意義。先編寫測試用例迫使我思考設計和架構,而不僅僅是把程式碼放到一起,讓它們可以執行。不要誤解我的意思,有很多時候,後者的方式更好。不過如果你想給確保這些不怎麼維護的程式碼在之後的幾周甚至幾個月依然工作,那麼測試驅動開發正是你需要的。

我研究了下整潔架構以及依賴注入模式,這些充分體現在 Router 類是如何作為一個更高層次的抽象的(實體?)。Router 類是比較接近核心的,像 http_parserApp 的內容比較邊緣化,因為它們只是完成了極小的字串和位元組流、或是中層 IO 的工作。測試驅動開發(TDD)迫使我獨立思考每個小部分,這使我問自己這樣的問題:方法呼叫的組合是否易於理解?類名是否準確地反映了我正在解決的問題?我的程式碼中是否很容易區分出不同的抽象層?

來吧,寫個小框架,真的很有趣:)


via: http://mattscodecave.com/posts/simple-python-framework-from-scratch.html

作者:Matt 譯者:Cathon 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

(題圖來自:es-static.us

相關文章