The Flask Mega-Tutorial 之 Chapter 15: A Better Application Structure (Blueprint)

Current Limitations

1、當前 application 含有多個 subsystems,但是相關 code 交錯分佈,無明確界限,難以複用。

  • User Authentication:

    • app/routes.py ,部分 view funcs,
    • app/forms.py ,部分 forms,
    • app/templates ,部分 templates,
    • app/email.py , email 支持。
  • Error Handling:

    • app/errors.py , error handlers
    • app/templates , 404.html & 500.html
  • Core Functionality:

    • posts display & write
    • user profiles
    • follow & unfollow
    • live translations of posts

organization logic 是,不同 module 對應實現不同的 functionality,如 module for view func, module for web_forms, module for errors, module for emails, dir for HTML templates, etc..
當 project 規模增大時,這些 modules 的數量會越來越多,某些 module 會越來越大,難以維護。(譬如,複用 authentication 組塊時,必須到不同的 module 中複製/粘貼,很不變;最理想的狀況時,能夠模塊分離。)

解決方案Blueprint (from Flask)


2、app/__init__.py 中的 application instance 是 global variable,不利於 testing

  • 無法實例化出 兩個 使用不同配置的 application instance,用於測試

    Imagine you want to test this application under different configurations. Because the application is defined as a global variable, there is really no way to instantiate two applications that use different configuration variables.

  • 由於所有 tests 共用一個 application,前面的 test 有可能更改了 application,干擾後續的 test

    Another situation that is not ideal is that all the tests use the same application, so a test could be making changes to the application that affect another test that runs later. Ideally you want all tests to run on a pristine application instance.


解決方案: 創建 application factory function,接收 configuration 爲 參數,返回不同配置的 application instance


Blueprints

  • Flask blueprint,是一種邏輯結構(logical structure),代表 application 的一個子單元(subset)。
  • Blueprint 可以包含多種元素,如 routes、view funcs、forms、templates 和 static files。
  • 如果將 blueprint 寫入 單獨的 python package,則此包封裝 application 某個功能特性相關的所有元素。

    In Flask, a blueprint is a logical structure that represents a subset of the application. A blueprint can include elements such as routes, view functions, forms, templates and static files. If you write your blueprint in a separate Python package, then you have a component that encapsulates the elements related to specific feature of the application.

  • Blueprint 內含的元素內容最初處於休眠狀態,爲關聯它們,必須將 blueprint 註冊到 application。(註冊過程中,blueprint 中已添加的內容將全部傳到 application)。

    So you can think of a blueprint as a temporary storage for application functionality that helps in organizing your code.


1、Error Handling 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/ ,移進原有的 app/errors.py ,並改名。
  • 創建 templates/errors/,移進 app/templates error handling 相關的 templates。
  • 創建生成藍圖的 app/errors/__init__.py
from flask import Blueprint

bp = Blueprint('errors', __name__)

from app.errors import handlers

注:若加參數 template_folder='templates',則 app/errors/templates/ 亦可使用。
生成 bp 後,引入 handlers 模塊,將模塊註冊到 bp 中。

so that the error handlers in it are registered with the blueprint. This import is at the bottom to avoid circular dependencies.

  • handlers.py 中引入 bp,將 @app.errorhandler 替換爲 @bp.app_errorhandler,並更改 template 路徑。
from app.errors import bp

@bp.app_errorhandler(404)
def not_found_error(error):
    return render_template('errors/404.html'), 404

@bp 更加加 independent of application,。

  • 註冊 bpapp/__init__.py
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!



Authentication 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 registration


1、 每個 module,只保留 auth 相關部分

  • email.py 中發密碼重置郵件的函數
#..
from app.email import send_email

def send_password_reset_email(user):
    #...


  • forms.py 中:
LoginForm()
RegistrationForm()
ResetPasswordRequestForm()
ResetPasswordForm()


  • routes.py 中:
login()
logout()
register()
reset_password_request()
reset_password(token)

(1) @app.route() 更新爲 bp.route()
(2) render_template() 中的 path 更新
(3) url_for() 添加相應 bp 前綴 ,如 url_for(‘login’) → url_for(‘auth.login’)
注: url_for() 的更改,包括 templates 中設置重定向的 *.html

2、註冊 authentication blueprintapp/__init__.py

# ...
from app.auth import bp as auth_bp
app.register_blueprint(auth_bp, url_prefix='/auth')
# ...

Flask gives you the option to attach a blueprint under a URL prefix, so any routes defined in the blueprint get this prefix in their URLs.
In many cases this is useful as a sort of “namespacing” that keeps all the routes in the blueprint separated from other routes in the application or other blueprints.



Main Application Blueprint

app/
    main/                               <-- blueprint package
        __init__.py                     <-- blueprint creation
        forms.py                        <-- main forms
        routes.py                       <-- main routes

    templates/
                                        <-- blueprint templates (位置不變)
            base.html
            index.html
            user.html
            _post.html
            edit_profile.html

    __init__.py                         <-- blueprint registration
  • Blueprint 名字爲bp,則所有調用 mainview funcurl_for() 都須加 main. 前綴。
  • main 相關的 templates 位置不變。

    Given that this is the core functionality of the application, we leave the templates in the same locations.
    This is not a problem because templates from the other two blueprints have been moved into sub-directories.

    -


Application Factory Function

  • 引入 Blueprints 前,因爲所有的 view funcserror handlers 需要的 @app 裝飾器都來自於 application,所以 application 必須是 global variable
  • 引入 Blueprints 後,所有的 view funcserror handlers 已移至 blueprints 中,所以 application 無需爲 global variable


# ...
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

(1) 先創建各種 extension 實例(global)。
(2) 定義 create_app(),先創建 Flask app,後依次 extension.init_app(app),將 extension 綁定到 application 上。
(3) not app.testing:如果 configTESTINGTrue,則略過後續的 email & logging


那麼, app/__init__.py 中的 create_app() 的調用者是 ?

Microblog / microblog.py
Microblog / tests.py

只有這兩處內創建的 application,才存在於 global scope


current_app

1、替換 code 中 的 appcurrent_app

app/models.py:      token相關 app.config['SECRET_KEY']
app/translate.py:   MS Translator API相關 app.config['MS_TRANSLATOR_KEY']
app/main/routes.py: pagination相關 app.config['POSTS_PER_PAGE'] 
app/__init__.py:    get_locale 相關 app.config['LANGUAGES']

全部替換爲 current_app (from Flask)


2、app/email.py 中涉及的 pass application instance to another thread.

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()
  • 首先,由於 current_app 是 context-aware 的變量(tied to the thread that is handling the client request),所以將其傳到 another thread無效。
  • 其次,current_app 實質是個 proxy object,動態地映射到 application instance 上。真正需要的是 application instance
  • current_app._get_current_object() 語句,提取 proxy object 內部真實的 application instance


3、app / cli.py 中註冊的 custom commands

  • 首先,創建 register() 函數,然後將 custom commands 移入,將 app instance 作爲參數傳入。
  • 然後,app / microblog.py 中,在創建 app 後,調用 register(app)

爲何不延續 current_app 的思路呢?

答: current_app 變量只侷限於處理 request 時發揮作用,但 custom commands 是在 start up 時註冊,而不是請求中。

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."""
        # ...


4、重構 Microblog / 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}



Unit Testing Improvements

1、定製 TestConfigMicroblog/tests.py

from config import Config

class TestConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = 'sqlite://'


2、爲 each test 創建各自的 app instanceMicroblog/tests.py

from app import create_app, db
#...

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()

在調用 db.create_all() 之前,db 必須先從 app.config 獲得 db URI,但 app factory func 有可能已生成多個 app

那如何使 db 使用 self.app=create_app() 剛剛生成的 app 呢?
答:利用 application context

  • current_app 變量,作爲 app proxy,會在 current thread 中尋找 活躍的 application context,一旦找到,就可從中獲取到 app
  • current_app 未找到 application context,則無法知曉活躍的 app,最終拋出異常。

    Remember the current_app variable, which somehow acts as a proxy for the application when there is no global application to import? This variable looks for an active application context in the current thread, and if it finds one, it gets the application from it. If there is no context, then there is no way to know what application is active, so current_app raises an exception.

  • 拿 python 測試(勿用 flaskshell,因爲會自動激活 application context
    application context
    可以發現,直接運行 current_app 時,拋出 RuntimeError: Working outside of application context.


3、app_context().push()app_context().pop()

  • Flask 在調用 view func 處理一個 request 之前,會先 push 一個application context ,從而激活 current_appg 變量
  • request 處理結束,context 會被 pop 移掉 (連同 context 激活的種種變量)。
  • -

Before invoking your view functions, Flask pushes an application context, which brings current_app and g to life.
When the request is complete, the context is removed, along with these variables.

For the db.create_all() call to work in the unit testing setUp() method, we push an application context for the application instance just created, and in that way, db.create_all() can use current_app.config to know where is the database.
Then in the tearDown() method I pop the context to reset everything to a clean state.


聯想到 app/emai.py 中,send_email() 開啓一個新 thread,然後將 app instance 作爲參數傳入。
傳入的 app instance,在 send_async_email() 中首先激活 app_context(),然後在此內執行任務。

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()


4、Flask 有兩類 context,


  • application contextcurrent_appg

  • request contextrequestsessioncurrent_user(flask-login’s)

request context is more specific and applies to a request.
When a request context is activated right before a request is handled, Flask’s request and session variables become available, as well as Flask-Login’s current_user.

5、tests.py 測試結果
這裏寫圖片描述



Environment Variables (使用 .env)

  • 每次打開(重開)一個 terminal session時,都須將 microblog / config.py 中的環境變量設置一番…..

    • 優化方案:在 root application dir中,創建 。env 文件,寫入環境變量。


  • 支持.env 的第三方庫:python-dotenv

    (venv) $ pip install python-dotenv

  • microblog/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):
    # ...
  • root application dir 下創建 .env

    注:勿加入 source control

注意,.env 無法用於環境變量 FLASK_APPFLASK_DEBUG ,因爲這兩者在 application 引導過程時即需要使用,當時 app instanceconfig obj 尚不存在。

The .env file can be used for all the configuration-time variables, but it cannot be used for Flask’s FLASK_APP and FLASK_DEBUG environment variables, because these are needed very early in the application bootstrap process, before the application instance and its configuration object exist.

-


Requirements File

  • 生成
(venv) $ pip freeze > requirements.txt

requirments.txt format

alembic==0.9.9
async-timeout==3.0.0
attrs==18.1.0
Babel==2.6.0


  • 導入安裝
    To create a same virtual environment on another machine, instead of installing packages one by one, just:
(venv) $ pip install -r requirements.txt


若使用 pipenv

pipenv install -r path/to/requirements.tx

If your requirements file has version numbers pinned, you’ll likely want to edit the new Pipfile to remove those, and let pipenv keep track of pinning.
If you want to keep the pinned versions in your Pipfile.lock for now, run pipenv lock --keep-outdated. Make sure to upgrade soon!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章