1. 程式人生 > >Flask中的請求上下文和應用上下文

Flask中的請求上下文和應用上下文

之前 none 如果 理念 返回值 自己的 .data 內置 .config

 本文章粘貼自  https://blog.tonyseek.com/post/the-context-mechanism-of-flask/

用過 Flask 做 Web 開發的同學應該不會不記得 App Context 和 Request Context 這兩個名字——這兩個 Context 算是 Flask 中比較特色的設計。[1]

從一個 Flask App 讀入配置並啟動開始,就進入了 App Context,在其中我們可以訪問配置文件、打開資源文件、通過路由規則反向構造 URL。[2] 當一個請求進入開始被處理時,就進入了 Request Context,在其中我們可以訪問請求攜帶的信息,比如 HTTP Method、表單域等。[3]

所以,這兩個 Context 也成了 Flask 框架復雜度比較集中的地方,對此有評價認為 Flask 的這種設計比 Django、Tornado 等框架的設計更為晦澀。[4] 我不認同這種評價。對於一個 Web 應用來說,“應用” 和 “請求” 的兩級上下文在理念上是現實存在的,如果理解了它們,那麽使用 Flask 並不會晦澀;即使是使用 Django、Tornado,理解了它們的 Context 也非常有利於做比官網例子更多的事情(例如編寫 Middleware)。

我因為開發 Flask 擴展,對這兩個 Context 的具體實現也研究了一番,同時還解決了一些自己之前“知道結論不知道過程”的疑惑,所以撰寫本文記錄下來。

Thread Local 的概念

從面向對象設計的角度看,對象是保存“狀態”的地方。Python 也是如此,一個對象的狀態都被保存在對象攜帶的一個特殊字典中,可以通過 vars 函數拿到它。

Thread Local 則是一種特殊的對象,它的“狀態”對線程隔離 —— 也就是說每個線程對一個 Thread Local 對象的修改都不會影響其他線程。這種對象的實現原理也非常簡單,只要以線程的 ID 來保存多份狀態字典即可,就像按照門牌號隔開的一格一格的信箱。

在 Python 中獲得一個這樣的 Thread Local 最簡單的方法是 threading.local()

>>> import
threading >>> storage = threading.local() >>> storage.foo = 1 >>> print(storage.foo) 1 >>> class AnotherThread(threading.Thread): ... def run(self): ... storage.foo = 2 ... print(storage.foo) # 這這個線程裏已經修改了 >>> >>> another = AnotherThread() >>> another.start() 2 >>> print(storage.foo) # 但是在主線程裏並沒有修改 1

Werkzeug 實現的 Local Stack 和 Local Proxy

Werkzeug 沒有直接使用 threading.local,而是自己實現了 werkzeug.local.Local 類。後者和前者有一些區別:

  • 後者會在 Greenlet 可用的情況下優先使用 Greenlet 的 ID 而不是線程 ID 以支持 Gevent 或 Eventlet 的調度,前者只支持多線程調度;
  • 後者實現了 Werkzeug 定義的協議方法 __release_local__,可以被 Werkzeug 自己的 release_pool 函數釋放(析構)掉當前線程下的狀態,前者沒有這個能力。

除 Local 外,Werkzeug 還實現了兩種數據結構:LocalStack 和 LocalProxy。

LocalStack 是用 Local 實現的棧結構,可以將對象推入、彈出,也可以快速拿到棧頂對象。當然,所有的修改都只在本線程可見。和 Local 一樣,LocalStack 也同樣實現了支持 release_pool 的接口。

LocalProxy 則是一個典型的代理模式實現,它在構造時接受一個 callable 的參數(比如一個函數),這個參數被調用後的返回值本身應該是一個 Thread Local 對象。對一個 LocalProxy 對象的所有操作,包括屬性訪問、方法調用(當然方法調用就是屬性訪問)甚至是二元操作 [6] 都會轉發到那個 callable 參數返回的 Thread Local 對象上。

LocalProxy 的一個使用場景是 LocalStack 的 __call__ 方法。比如 my_local_stack 是一個 LocalStack 實例,那麽 my_local_stack() 能返回一個 LocalProxy 對象,這個對象始終指向 my_local_stack 的棧頂元素。如果棧頂元素不存在,訪問這個 LocalProxy 的時候會拋出 RuntimeError

Flask 基於 Local Stack 的 Context

Flask 是一個基於 Werkzeug 實現的框架,所以 Flask 的 App Context 和 Request Context 也理所當然地基於 Werkzeug 的 Local Stack 實現。

在概念上,App Context 代表了“應用級別的上下文”,比如配置文件中的數據庫連接信息;Request Context 代表了“請求級別的上下文”,比如當前訪問的 URL。

這兩種上下文對象的類定義在 flask.ctx 中,它們的用法是推入 flask.globals 中創建的 _app_ctx_stack_request_ctx_stack 這兩個單例 Local Stack 中。因為 Local Stack 的狀態是線程隔離的,而 Web 應用中每個線程(或 Greenlet)同時只處理一個請求,所以 App Context 對象和 Request Context 對象也是請求間隔離的。

app = Flask(__name__) 構造出一個 Flask App 時,App Context 並不會被自動推入 Stack 中。所以此時 Local Stack 的棧頂是空的,current_app 也是 unbound 狀態。

>>> from flask import Flask
>>> from flask.globals import _app_ctx_stack, _request_ctx_stack
>>>
>>> app = Flask(__name__)
>>> _app_ctx_stack.top
>>> _request_ctx_stack.top
>>> _app_ctx_stack()
<LocalProxy unbound>
>>>
>>> from flask import current_app
>>> current_app
<LocalProxy unbound>

這也是一些 Flask 用戶可能被坑的地方 —— 比如編寫一個離線腳本時,如果直接在一個 Flask-SQLAlchemy 寫成的 Model 上調用 User.query.get(user_id),就會遇到 RuntimeError。因為此時 App Context 還沒被推入棧中,而 Flask-SQLAlchemy 需要數據庫連接信息時就會去取 current_app.config,current_app 指向的卻是 _app_ctx_stack 為空的棧頂。

解決的辦法是運行腳本正文之前,先將 App 的 App Context 推入棧中,棧頂不為空後 current_app 這個 Local Proxy 對象就自然能將“取 config 屬性” 的動作轉發到當前 App 上了:

>>> ctx = app.app_context()
>>> ctx.push()
>>> _app_ctx_stack.top
<flask.ctx.AppContext object at 0x102eac7d0>
>>> _app_ctx_stack.top is ctx
True
>>> current_app
<Flask __main__>
>>>
>>> ctx.pop()
>>> _app_ctx_stack.top
>>> current_app
<LocalProxy unbound>

那麽為什麽在應用運行時不需要手動 app_context().push() 呢?因為 Flask App 在作為 WSGI Application 運行時,會在每個請求進入的時候將請求上下文推入 _request_ctx_stack 中,而請求上下文一定是 App 上下文之中,所以推入部分的邏輯有這樣一條:如果發現 _app_ctx_stack為空,則隱式地推入一個 App 上下文。

所以,請求中是不需要手動推上下文入棧的,但是離線腳本需要手動推入 App Context。如果沒有什麽特殊困難,我更建議用 Flask-Script 來寫離線任務。[7]

兩個疑問

到此為止,就出現兩個疑問:

  • 為什麽 App Context 要獨立出來:既然在 Web 應用運行時裏,App Context 和 Request Context 都是 Thread Local 的,那麽為什麽還要獨立二者?
  • 為什麽要放在“棧”裏:在 Web 應用運行時中,一個線程同時只處理一個請求,那麽 _req_ctx_stack_app_ctx_stack 肯定都是只有一個棧頂元素的。那麽為什麽還要用“棧”這種結構?

我最初也被這兩個疑問困惑過。後來看了一些資料,就明白了 Flask 為何要設計成這樣。這兩個做法給予我們 多個 Flask App 共存非 Web Runtime 中靈活控制 Context 的可能性。

我們知道對一個 Flask App 調用 app.run() 之後,進程就進入阻塞模式並開始監聽請求。此時是不可能再讓另一個 Flask App 在主線程運行起來的。那麽還有哪些場景需要多個 Flask App 共存呢?前面提到了,一個 Flask App 實例就是一個 WSGI Application,那麽 WSGI Middleware 是允許使用組合模式的,比如:

from werkzeug.wsgi import DispatcherMiddleware
from biubiu.app import create_app
from biubiu.admin.app import create_app as create_admin_app

application = DispatcherMiddleware(create_app(), {
    /admin: create_admin_app()
})

這個例子就利用 Werkzeug 內置的 Middleware 將兩個 Flask App 組合成一個一個 WSGI Application。這種情況下兩個 App 都同時在運行,只是根據 URL 的不同而將請求分發到不同的 App 上處理。

Note

需要註意的是,這種用法和 Flask 的 Blueprint 是有區別的。Blueprint 雖然和這種用法很類似,但前者自己沒有 App Context,只是同一個 Flask App 內部整理資源的一種方式,所以多個 Blueprint 可能共享了同一個 Flask App;後者面向的是所有 WSGI Application,而不僅僅是 Flask App,即使是把一個 Django App 和一個 Flask App 用這種用法整合起來也是可行的。

如果僅僅在 Web Runtime 中,多個 Flask App 同時工作倒不是問題。畢竟每個請求被處理的時候是身處不同的 Thread Local 中的。但是 Flask App 不一定僅僅在 Web Runtime 中被使用 —— 有兩個典型的場景是在非 Web 環境需要訪問上下文代碼的,一個是離線腳本(前面提到過),另一個是測試。這兩個場景即所謂的“Running code outside of a request”。

在非 Web 環境運行 Flask 關聯的代碼

離線腳本或者測試這類非 Web 環境和和 Web 環境不同 —— 前者一般只在主線程運行。

設想,一個離線腳本需要操作兩個 Flask App 關聯的上下文,應該怎麽辦呢?這時候棧結構的 App Context 優勢就發揮出來了。

from biubiu.app import create_app
from biubiu.admin.app import create_app as create_admin_app

app = create_app()
admin_app = create_admin_app()

def copy_data():
    with app.app_context():
        data = read_data()  # fake function for demo
        with admin_app.app_context():
            write_data(data)  # fake function for demo
        mark_data_copied()  # fake function for demo

無論有多少個 App,只要主動去 Push 它的 App Context,Context Stack 中就會累積起來。這樣,棧頂永遠是當前操作的 App Context。當一個 App Context 結束的時候,相應的棧頂元素也隨之出棧。如果在執行過程中拋出了異常,對應的 App Context 中註冊的 teardown 函數被傳入帶有異常信息的參數。

這麽一來就解釋了兩個疑問 —— 在這種單線程運行環境中,只有棧結構才能保存多個 Context 並在其中定位出哪個才是“當前”。而離線腳本只需要 App 關聯的上下文,不需要構造出請求,所以 App Context 也應該和 Request Context 分離。

另一個手動推入 Context 的場景是測試。測試中我們可能會需要構造一個請求,並驗證相關的狀態是否符合預期。例如:

def test_app():
    app = create_app()
    client = app.test_client()
    resp = client.get(/)
    assert Home in resp.data

這裏調用 client.get 時,Request Context 就被推入了。其特點和 App Context 非常類似,這裏不再贅述。

[1] Flask 文檔對 Application Context 和 Request Context 作出了詳盡的解釋;
[2] 通過訪問 flask.current_app
[3] 通過訪問 flask.request
[4] Flask(Werkzeug) 的 Context 基於 Thread Local 和代理模式實現,只要身處 Context 中就能用近似訪問全局變量的的方式訪問到上下文信息,例如 flask.current_appflask.request;Django 和 Tornado 則將上下文封裝在對象中,只有明確獲取了相關上下文對象才能訪問其中的信息,例如在視圖函數中或按照規定模板實現的 Middleware 中;
[5] 基於 Flask 的 Web 應用可以在 Gevent 或 Eventlet 異步網絡庫 patch 過的 Python 環境中正常工作。這二者都使用 Greenlet 而不是系統線程作為調度單元,而 Werkzeug 考慮到了這點,在 Greenlet 可用時用 Greenlet ID 代替線程 ID。
[6] Python 的對象方法是 Descriptior 實現的,所以方法就是一種屬性;而 Python 的二元操作可以用雙下劃線開頭和結尾的一系列協議,所以 foo + bar 等同於 foo.__add__(bar),本質還是屬性訪問。
[7] Flask-Script 是一個用來寫 manage.py 管理腳本的 Flask 擴展,用它運行的任務會在開始前自動推入 App Context。將來這個“運行任務”的功能將被整合到 Flask 內部。
[8] 詳見 Flask 源碼中的 setup_method 裝飾器

推送程序上下文:app = Flask(xxx), app.app_context().push() 推送了程序上下文,g可以使用,當前線程的current_app指向app

    

Flask中的請求上下文和應用上下文