01
目前的侷限性
目前狀態下的應用有兩個基本問題。如果你觀察應用的組織方式,你會注意到有幾個不同的子系統可以被識別,但支持它們的代碼都混合在了一起,沒有任何明確的界限。我們來回顧一下這些子系統是什麼:
用戶認證子系統,包括app/routes.py中的一些視圖函數,app/forms.py中的一些表單,app/templates中的一些模板以及app/email.py中的電子郵件支持。
錯誤子系統,它在app/errors.py中定義了錯誤處理程序並在app/templates中定義了模板。
核心應用功能,包括顯示和撰寫用戶動態,用戶個人主頁和關注以及用戶動態的實時翻譯,這些功能遍佈大多數應用模塊和模板。
思考這三個子系統以及它們組織的方式,你可能會注意到這樣一個模式。到目前爲止,我一直遵循的組織邏輯是不同的應用功能歸屬到其專屬的模塊。這些模塊之中,一個用於視圖函數,一個用於Web表單,一個用於錯誤,一個用於電子郵件,一個目錄用於存放HTML模板等等。雖然這是一個對小項目有意義的組織結構,但是一旦項目開始增長,它往往會使其中的一些模塊變得非常大而且雜亂無章。
要想清晰地看到問題的一種方法,是思考如何通過儘可能多地重複使用這一項目來開始第二個項目。例如,用戶身份驗證部分應該在其他應用中也能運行良好,但如果你想按原樣使用該代碼,則必須進入多個模塊並將相關部分複製/粘貼到新項目的新文件中。看到這是多麼不方便了嗎?如果這個項目將所有與認證相關的文件從應用的其餘部分中分離出來,會不會更好?Flask的blueprints功能有助於實現更實用的組織結構,從而更輕鬆地重用代碼。
還有第二個問題,雖然它不太明顯。Flask應用實例在app/__init__.py
中被創建爲一個全局變量,然後又被很多應用模塊導入。雖然這本身並不是問題,但將應用實例作爲全局變量可能會使某些情況複雜化,特別是與測試相關的情景。想象一下你想要在不同的配置下測試這個應用。由於應用被定義爲全局變量,實際上沒有辦法使用不同配置變量來實例化的兩個應用實例。另一種糟心的情況是,所有測試都使用相同的應用,因此測試可能會對應用進行更改,就會影響稍後運行的其他測試。理想情況下,你希望所有測試都在原始應用實例上運行的。
你可以在tests.py模塊中看到我正在使用的應用實例化之後修改配置的技巧,以指示測試時使用內存數據庫而不是默認的SQLite數據庫。我真的沒有其他辦法來更改已配置的數據庫,因爲在測試開始時已經創建和配置了應用。對於這種特殊情況,對已配置的應用實例修改配置似乎可以運行,但在其他情況下可能不會,並且在任何情況下,這是一種不推薦的做法,因爲這麼做可能會導致提示晦澀並且難以找到BUG。
更好的解決方案是不將應用設置爲全局變量,而是使用應用工廠函數在運行時創建它。 這將是一個接受配置對象作爲參數的函數,並返回一個配置完畢的Flask應用實例。如果我能夠通過應用工廠函數來修改應用,那麼編寫需要特殊配置的測試會變得很容易,因爲每個測試都可以創建它各自的應用。
在本章中,我將通過爲上面提到的三個子系統重構應用來介紹blueprints。展示更改的詳細列表有些不切實際,因爲幾乎應用中每個文件都有少許變化,所以我將討論重構的步驟,然後你可以下載更改後的應用。
02
Blueprints
在Flask中,blueprint是代表應用子集的邏輯結構。blueprint可以包括路由,視圖函數,表單,模板和靜態文件等元素。如果在單獨的Python包中編寫blueprint,那麼你將擁有一個封裝了應用特定功能的組件。
Blueprint的內容最初處於休眠狀態。爲了關聯這些元素,blueprint需要在應用中註冊。在註冊過程中,需要將添加到blueprint中的所有元素傳遞給應用。因此,你可以將blueprint視爲應用功能的臨時存儲,以幫助組織代碼。
03
錯誤處理 blueprints
我創建的第一個blueprint用於封裝對錯誤處理程序的支持。該blueprint的結構如下:
app/ errors/ <-- blueprint package __init__.py <-- blueprint creation handlers.py <-- error handlers templates/ errors/ <-- error templates 404.html 500.html __init__.py <-- blueprint registration
實質上,我所做的是將app/errors.py模塊移動到app/errors/handlers.py中,並將兩個錯誤模板移動到app/templates/errors中,以便將它們與其他模板分開。我還必須在兩個錯誤處理程序中更改render_template()
調用以使用新的errors模板子目錄。之後,我將blueprint創建添加到app/errors/__init__.py
模塊,並在創建應用實例之後,將blueprint註冊到app/__init__.py
。
我必須提一下,Flask blueprints可以爲自己的模板和靜態文件配置單獨的目錄。我已決定將模板移動到應用模板目錄的子目錄中,以便所有模板都位於一個層次結構中,但是如果你希望在blueprint中包含屬於自己的模板,這也是支持的。例如,如果向Blueprint()
構造函數添加template_folder='templates'
參數,則可以將錯誤blueprint的模板存儲在app/errors/templates目錄中。
創建blueprint與創建應用非常相似。這是在blueprint的___init__.py
模塊中完成的:
app/errors/__init__.py
:錯誤blueprint
from flask import Blueprint
bp = Blueprint('errors', __name__)
from app.errors import handlers
Blueprint
類獲取blueprint的名稱,基礎模塊的名稱(通常在Flask應用實例中設置爲__name__
)以及一些可選參數(在這種情況下我不需要這些參數)。Blueprint對象創建後,我導入了handlers.py模塊,以便其中的錯誤處理程序在blueprint中註冊。該導入位於底部以避免循環依賴。
在handlers.py模塊中,我放棄使用@app.errorhandler
裝飾器將錯誤處理程序附加到應用程序,而是使用blueprint的@bp.app_errorhandler
裝飾器。儘管兩個裝飾器最終都達到了相同的結果,但這樣做的目的是試圖使blueprint獨立於應用,使其更具可移植性。我還需要修改兩個錯誤模板的路徑,因爲它們被移動到了新errors子目錄。
完成錯誤處理程序重構的最後一步是嚮應用註冊blueprint:
app/__init__.py
:嚮應用註冊錯誤blueprint
app = Flask(__name__)
# ...
from app.errors import bp as errors_bp
app.register_blueprint(errors_bp)
# ...
from app import routes, models # <-- remove errors from this import!
爲了註冊blueprint,將使用Flask應用實例的register_blueprint()
方法。在註冊blueprint時,任何視圖函數,模板,靜態文件,錯誤處理程序等均連接到應用。我將blueprint的導入放在app.register_blueprint()
的上方,以避免循環依賴
04
用戶認證 blueprints
將應用的認證功能重構爲blueprint的過程與錯誤處理程序的過程非常相似。以下是重構爲blueprint的目錄層次結構:
app/ auth/ <-- blueprint package __init__.py <-- blueprint creation email.py <-- authentication emails forms.py <-- authentication forms routes.py <-- authentication routes templates/ auth/ <-- blueprint templates login.html register.html reset_password_request.html reset_password.html __init__.py
爲了創建這個blueprint,我必須將所有認證相關的功能移到爲blueprint創建的新模塊中。這包括一些視圖函數,Web表單和支持功能,例如通過電子郵件發送密碼重設token的功能。我還將模板移動到一個子目錄中,以將它們與應用的其餘部分分開,就像我對錯誤頁面所做的那樣。
在blueprint中定義路由時,使用@bp.route
裝飾器來代替@app.route
裝飾器。在url_for()
中用於構建URL的語法也需要進行更改。對於直接附加到應用的常規視圖函數,url_for()
的第一個參數是視圖函數名稱。但當在blueprint中定義路由時,該參數必須包含blueprint名稱和視圖函數名稱,並以句點分隔。因此,我不得不用諸如url_for('auth.login')
的代碼替換所有出現的url_for('login')
代碼,對於其餘的視圖函數也是如此。
註冊auth
blueprint到應用時,我使用了些許不同的格式:
app/__init__.py
:註冊用戶認證blueprint到應用
# ...from app.auth import bp as auth_bpapp.register_blueprint(auth_bp, url_prefix='/auth')# ...
在這種情況下,register_blueprint()
調用接收了一個額外的參數,url_prefix
。這完全是可選的,Flask提供了給blueprint的路由添加URL前綴的選項,因此blueprint中定義的任何路由都會在其完整URL中獲取此前綴。在許多情況下,這可以用來當成“命名空間”,它可以將blueprint中的所有路由與應用或其他blueprint中的其他路由分開。對於用戶認證,我認爲讓所有路由以 /auth 開頭很不錯,所以我添加了該前綴。所以現在登錄URL將會是 http://localhost:5000/auth/login 。因爲我使用url_for()
來生成URL,所有URL都會自動合併前綴
05
主應用 blueprints
第三個blueprint包含核心應用邏輯。重構這個blueprint和前兩個blueprint的過程一樣。我給這個blueprint命名爲main
,因此所有引用視圖函數的url_for()
調用都必須添加一個main.
前綴。鑑於這是應用的核心功能,我決定將模板留在原來的位置。這不會有什麼問題,因爲我已將其他兩個blueprint中的模板移動到子目錄中了
06
應用工廠模式
正如我在本章的介紹中所提到的,將應用設置爲全局變量會引入一些複雜性,主要是以某些測試場景的侷限性爲形式。在我介紹blueprint之前,應用必須是一個全局變量,因爲所有的視圖函數和錯誤處理程序都需要使用來自app
的裝飾器來修飾,比如@app.route
。但是現在所有的路由和錯誤處理程序都被轉移到了blueprint中,因此保持應用全局性的理由就不夠充分了。
所以我要做的是添加一個名爲create_app()
的函數來構造一個Flask應用實例,並消除全局變量。轉換並非容易,我不得不理清一些複雜的東西,但我們先來看看應用工廠函數:
app/__init__.py
:應用工廠函數
# ...
db = SQLAlchemy()
migrate = Migrate()
login = LoginManager()
login.login_view = 'auth.login'
login.login_message = _l('Please log in to access this page.')
mail = Mail()
bootstrap = Bootstrap()
moment = Moment()
babel = Babel()
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
db.init_app(app)
migrate.init_app(app, db)
login.init_app(app)
mail.init_app(app)
bootstrap.init_app(app)
moment.init_app(app)
babel.init_app(app)
# ... no changes to blueprint registration
if not app.debug and not app.testing:
# ... no changes to logging setup
return app
你已經看到,大多數Flask插件都是通過創建插件實例並將應用作爲參數傳遞來初始化的。當應用不再作爲全局變量時,有一種替代模式,插件分成兩個階段進行初始化。插件實例首先像前面一樣在全局範圍內創建,但沒有參數傳遞給它。這會創建一個未附加到應用的插件實例。當應用實例在工廠函數中創建時,必須在插件實例上調用init_app()
方法,以將其綁定到現在已知的應用。
在初始化期間執行的其他任務保持不變,但會被移到工廠函數而不是在全局範圍內。這包括blueprint和日誌配置的註冊。請注意,我在條件中添加了一個not app.testing
子句,用於決定是否啓用電子郵件和文件日誌,以便在單元測試期間跳過所有這些日誌記錄。由於在配置中TESTING
變量在單元測試時會被設置爲True
,因此app.testing
標誌在運行單元測試時將變爲True
。
那麼誰來調用應用程工廠函數呢?最明顯使用此函數的地方是處於頂級目錄的microblog.py腳本,它是唯一會將應用設置爲全局變量的模塊。另一個調用該工廠函數的地方是tests.py,我將在下一節中更詳細地討論單元測試。
正如我上面提到的,大多數對app
的引用都是隨着blueprint的引入而消失的,但是我仍然需要解決代碼中的一些問題。例如,app/models.py、app/translate.py和app/main/routes.py模塊都引用了app.config
。幸運的是,Flask開發人員試圖使視圖函數很容易地訪問應用實例,而不必像我一直在做的那樣導入它。Flask提供的current_app
變量是一個特殊的“上下文”變量,Flask在分派請求之前使用應用初始化該變量。你之前已經看到另一個上下文變量,即存儲當前語言環境的g
變量。這兩個變量,以及Flask-Login的current_user
和其他一些你還沒有看到的東西,是“魔法”變量,因爲它們像全局變量一樣工作,但只能在處理請求期間且在處理它的線程中訪問。
用Flask的current_app
變量替換app
就不需要將應用實例作爲全局變量導入。通過簡單的搜索和替換,我可以毫無困難地用current_app.config
替換對app.config
的所有引用。
app/email.py模塊提出了一個更大的挑戰,所以我必須使用一個小技巧:
app/email.py:將應用實例傳遞給另一個線程。
from flask import current_app
def send_async_email(app, msg):
with app.app_context():
mail.send(msg)
def send_email(subject, sender, recipients, text_body, html_body):
msg = Message(subject, sender=sender, recipients=recipients)
msg.body = text_body
msg.html = html_body
Thread(target=send_async_email,
args=(current_app._get_current_object(), msg)).start()
在send_email()
函數中,應用實例作爲參數傳遞給後臺線程,後臺線程將發送電子郵件而不阻塞主應用程序。在作爲後臺線程運行的send_async_email()
函數中直接使用current_app
將不會奏效,因爲current_app
是一個與處理客戶端請求的線程綁定的上下文感知變量。在另一個線程中,current_app
沒有賦值。直接將current_app
作爲參數傳遞給線程對象也不會有效,因爲current_app
實際上是一個代理對象,它被動態地映射到應用實例。因此,傳遞代理對象與直接在線程中使用current_app
相同。我需要做的是訪問存儲在代理對象中的實際應用程序實例,並將其作爲app
參數傳遞。 current_app._get_current_object()
表達式從代理對象中提取實際的應用實例,所以它就是我作爲參數傳遞給線程的。
另一個棘手的模塊是app/cli.py,它實現了一些用於管理語言翻譯的快捷命令。在這種情況下,current_app
變量不起作用,因爲這些命令是在啓動時註冊的,而不是在處理請求期間(這是唯一可以使用current_app
的時間段)註冊的。爲了在這個模塊中刪除對app
的引用,我使用了另一個技巧,將這些自定義命令移動到一個將app
實例作爲參數的register()
函數中:
app/cli.py:註冊自定義應用命令。
import os
import click
def register(app):
@app.cli.group()
def translate():
"""Translation and localization commands."""
pass
@translate.command()
@click.argument('lang')
def init(lang):
"""Initialize a new language."""
# ...
@translate.command()
def update():
"""Update all languages."""
# ...
@translate.command()
def compile():
"""Compile all languages."""
# ...
然後我從microblog.py中調用這個register()
函數。以下是完成重構後的microblog.py:
microblog.py:重構後的主應用模塊。
from app import create_app, db, cli
from app.models import User, Post
app = create_app()
cli.register(app)
@app.shell_context_processor
def make_shell_context():
return {'db': db, 'User': User, 'Post' :Post}
07
單元測試改進
正如我在本章開頭所暗示的,到目前爲止,我所做的很多工作都是爲了改進單元測試工作流程。在運行單元測試時,要確保應用的配置方式不會污染開發資源(如數據庫)。
tests.py的當前版本採用了應用實例化之後修改配置的技巧,這是一種危險的做法,因爲並不是所有類型的更改都會在修改之後才生效。我想要的是有機會在添加到應用之前指定我想要的測試配置項。
create_app()
函數現在接受一個配置類作爲參數。默認情況下,使用在config.py中定義的Config
類,但現在我可以通過將新類傳遞給工廠函數來創建使用不同配置的應用實例。下面是一個適用於我的單元測試的示例配置類:
tests.py:測試配置。
from config import Config
class TestConfig(Config):
TESTING = True
SQLALCHEMY_DATABASE_URI = 'sqlite://'
我在這裏做的是創建應用的Config
類的子類,並覆蓋SQLAlchemy配置以使用內存SQLite數據庫。我還添加了一個TESTING
屬性,並設置爲True
,我目前不需要該屬性,但如果應用需要確定它是否在單元測試下運行,它就派上用場了。
你一定還記得,我的單元測試依賴於setUp()
和tearDown()
方法,它們由單元測試框架自動調用,以創建和銷燬每次測試運行的環境。我現在可以使用這兩種方法爲每個測試創建和銷燬一個測試專用的應用:
tests.py:爲每次測試創建一個應用。
class UserModelCase(unittest.TestCase):
def setUp(self):
self.app = create_app(TestConfig)
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
def tearDown(self):
db.session.remove()
db.drop_all()
self.app_context.pop()
新的應用將存儲在self.app
中,但光是創建一個應用不足以使所有的工作都成功。思考創建數據庫表的db.create_all()
語句。 db
實例需要註冊到應用實例,因爲它需要從app.config
獲取數據庫URI,但是當你使用應用工廠時,應用就不止一個了。那麼db
如何關聯到我剛剛創建的self.app
實例呢?
答案在application context中。還記得current_app
變量嗎?當不存在全局應用實例導入時,該變量以代理的形式來引用應用實例。這個變量在當前線程中查找活躍的應用上下文,如果找到了,它會從中獲取應用實例。如果沒有上下文,那麼就沒有辦法知道哪個應用實例處於活躍狀態,所以current_app
就會引發一個異常。下面你可以看到它是如何在Python控制檯中工作的。這需要通過運行python
啓動,因爲flask shell
命令會自動激活應用程序上下文以方便使用。
>>> from flask import current_app
>>> current_app.config['SQLALCHEMY_DATABASE_URI']
Traceback (most recent call last):
...
RuntimeError: Working outside of application context.
>>> from app import create_app
>>> app = create_app()
>>> app.app_context().push()
>>> current_app.config['SQLALCHEMY_DATABASE_URI']
'sqlite:////home/miguel/microblog/app.db'
這就是祕密所在!在調用你的視圖函數之前,Flask推送一個應用上下文,它會使current_app
和g
生效。當請求完成時,上下文將與這些變量一起被刪除。爲了使db.create_all()
調用在單元測試setUp()
方法中工作,我爲剛剛創建的應用程序實例推送了一個應用上下文,這樣db.create_all()
可以使用 current_app.config
知道數據庫在哪裏。然後在tearDown()
方法中,我彈出上下文以將所有內容重置爲乾淨狀態。
你還應該知道,應用上下文是Flask使用的兩種上下文之一,還有一個請求上下文,它更具體,因爲它適用於請求。在處理請求之前激活請求上下文時,Flask的request
、session
以及Flask-Login的current_user
變量纔會變成可用狀態。
08
環境變量
正如構建此應用時你所看到的,在啓動服務器之前,有許多配置選項取決於在環境中設置的變量。這包括密鑰、電子郵件服務器信息、數據庫URL和Microsoft Translator API key。你可能會和我一樣覺得,這很不方便,因爲每次打開新的終端會話時,都需要重新設置這些變量。
譯者注:可以通過將環境變量設置到開機啓動中,來保持它們在該計算機中的所有終端中都生效。
應用依賴大量環境變量的常見處理模式是將這些變量存儲在應用根目錄中的 .env 文件中。應用在啓動時會從此文件中導入變量,這樣就不需要你手動設置這些變量了。
有一個支持 .env 文件的Python包,名爲python-dotenv
。所以讓我們安裝這個包:
(venv) $ pip install python-dotenv
由於config.py模塊是我讀取所有環境變量的地方,因此我將在創建Config類之前導入 .env 文件,以便在構造類時設置變量:
config.py:導入 .env 文件中的環境變量。
import os
from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(basedir, '.env'))
class Config(object):
# ...
現在你可以創建一個 .env 文件並在其中寫入應用所需的所有環境變量了。不要將 .env 文件加入到源代碼版本控制中,這非常重要。否則,一旦你的密碼和其他重要信息上傳到遠程代碼庫中後,你就會後悔莫及。
.env文件可以用於所有配置變量,但是不能用於Flask命令行的FLASK_APP
和FLASK_DEBUG
環境變量,因爲它們在應用啓動的早期(應用實例和配置對象存在之前)就被使用了。
以下示例顯示了 .env 文件,該文件定義了一個安全密鑰,將電子郵件配置爲在本地運行的郵件服務器的25端口上,並且不進行身份驗證,設置Microsoft Translator API key,使用數據庫配置的默認值:
SECRET_KEY=a-really-long-and-unique-key-that-nobody-knowsMAIL_SERVER=localhostMAIL_PORT=25MS_TRANSLATOR_KEY=<your-translator-key-here>
09
依賴文件
此時我已經在Python虛擬環境中安裝了一定數量的軟件包。如果你需要在另一臺機器上重新生成你的環境,將無法記住你必須安裝哪些軟件包,所以一般公認的做法是在項目的根目錄中寫一個requirements.txt文件,列出所有依賴的包及其版本。生成這個列表實際上很簡單:
(venv) $ pip freeze > requirements.txt
pip freeze
命令將安裝在虛擬環境中的所有軟件包以正確的格式輸入到requirements.txt文件中。現在,如果你需要在另一臺計算機上創建相同的虛擬環境,無需逐個安裝軟件包,可以直接運行一條命令實現:
(venv) $ pip install -r requirements.txt