Skip to content

Flask Web Development 07

changwu edited this page Mar 21, 2016 · 20 revisions

第七章

大型應用程式架構

p.95

在 flask, 雖然應用程式可以撰寫在一個 script 檔案中, 但是當應用變大時, 就不容易 scale, 加上 flask 本身不像其他框架, 強加要如何組織檔案架構, 反而將檔案組織留給開發者本身, 因此如何維護大型應用將在本章探討.

切到本章 code

$ git checkout 7a

flasky 檔案架構

$ tree ../flasky
../flasky
├── LICENSE
├── README.md
├── app
│   ├── __init__.py
│   ├── email.py
│   ├── main
│   │   ├── __init__.py
│   │   ├── errors.py
│   │   ├── forms.py
│   │   └── views.py
│   ├── models.py
│   ├── static
│   │   └── favicon.ico
│   └── templates
│       ├── 404.html
│       ├── 500.html
│       ├── base.html
│       ├── index.html
│       └── mail
│           ├── new_user.html
│           └── new_user.txt
├── config.py
├── data.sqlite
├── hello.pyc
├── manage.py
├── migrations
│   ├── README
│   ├── alembic.ini
│   ├── env.py
│   ├── env.pyc
│   ├── script.py.mako
│   └── versions
│       ├── 22e40d65b3cb_initial_migration.pyc
│       └── 38c4e85512a9_initial_migration.py
├── requirements.txt
└── tests
    ├── __init__.py
    └── test_basics.py

8 directories, 30 files

最上層的四個資料夾:

  1. app/ 包含 flask 應用
  2. migrations/ 資料庫遷移的指令檔
  3. test/ 做 unit test 測試
  4. venv/ Python 的環境 (我的部份沒有, 因為不是用 virtualenv)

其他檔案

  1. requirements.txt 應用需要的套件
  2. config.py 應用的設定檔
  3. manage.py 啟動應用或其他工作的管理檔

本章將探討如何將 hello.py 應用拆成以上的結構

設定檔選項 (Configuration Options)

在 hello.py 中, 所有的設定都寫在單一檔案, 但是在開發工作中, 通常會依環境需求需要不同的設定檔

  1. development
  2. testing
  3. production

為了不讓彼此相互干擾, 不直接覆寫 dictionary 的設定值, 將設定檔抽出來成立一個結構式的 configuration class, 放在 config.py

  • config.py
import os
basedir = os.path.abspath(os.path.dirname(__file__))


class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string'
    SQLALCHEMY_COMMIT_ON_TEARDOWN = True
    MAIL_SERVER = 'smtp.googlemail.com'
    MAIL_PORT = 587
    MAIL_USE_TLS = True
    MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
    MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
    FLASKY_MAIL_SUBJECT_PREFIX = '[Flasky]'
    FLASKY_MAIL_SENDER = 'Flasky Admin <[email protected]>'
    FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN')

    @staticmethod
    def init_app(app):
        pass


class DevelopmentConfig(Config):
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
        'sqlite:///' + os.path.join(basedir, 'data-dev.sqlite')


class TestingConfig(Config):
    TESTING = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
        'sqlite:///' + os.path.join(basedir, 'data-test.sqlite')


class ProductionConfig(Config):
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
        'sqlite:///' + os.path.join(basedir, 'data.sqlite')


config = {
    'development': DevelopmentConfig,
    'testing': TestingConfig,
    'production': ProductionConfig,
    'default': DevelopmentConfig
}

在 Config base class 中, 存放共用的設定, 其他類別則繼承 Config 的設定, 通常開發與上線的資料庫可能有所不同, 抽出來在不同類設定顯得更有彈性, config 指定預設採 Development 的設定.

應用套件 (Application Package)

如果將單一應用看成一個套件, 應用中所包含的 application code, templates 及 static files 全放在一起, 形成一個 app, 對於 app 的應用可以有特定的名稱, 在此先稱為 app. 由於在 flask 中, templatesstatic 本身就是應用的一部分, 因此這兩個資料夾會一併移到 app, database models 與 email support 同樣也一併移入 app 中, 形成 app/models.pyapp/email.py.

使用應用工廠 (Using an Application Factory)

所有應用都放在單一檔案, 固然方便, 但是當應用啟動後, 就無法動態改變設定, 因為應用存在 global scope. 為了解決此問題, factory function 允許應用可以產生多個 instance 並伴隨不同的設定檔.

  • app/init.py

在建構子當中, 每個應用 instance 都需要使用共同的 Flask extensions, 因此在建構子中, 先是不傳遞參數來呼叫這些擴充套件的建構子, 例如原本應該是 db = SQLAlchemy(app), 現在是 db = SQLAlchemy(), 等到 create_app() 時, 除了導入之前的設定檔, 在 app instance 生成後, 才初始化擴充套件 db.init_app(app).

from flask import Flask
from flask.ext.bootstrap import Bootstrap
from flask.ext.mail import Mail
from flask.ext.moment import Moment
from flask.ext.sqlalchemy import SQLAlchemy
from config import config

bootstrap = Bootstrap()
mail = Mail()
moment = Moment()
db = SQLAlchemy()


def create_app(config_name):
    app = Flask(__name__)
    app.config.from_object(config[config_name])
    config[config_name].init_app(app)

    bootstrap.init_app(app)
    mail.init_app(app)
    moment.init_app(app)
    db.init_app(app)

    from .main import main as main_blueprint
    app.register_blueprint(main_blueprint)

    return app

到目前為止, 工廠模式可用來建立 app instance, 但是狀態並不完整, routes 以及 error page handlers 還沒定義.

Implementing Application Functionality in a Blueprint

但由於工廠模式, 使得 route 變得複雜, 在單一檔案中, application instance 屬於全域變數 (global scope), 所以路由可透過修飾子 app.route decorator 來處理. 但現在應用的產生屬於 runtime, 在 create_app() 後, app 才會產生但已經太遲, app.errorhandler decorator 同樣遭遇相同問題.

沒搞懂

flask 中提供一種使用 blueprint 的解法, blueprint 像模具般, 也可以用來設定 route, 無論是在單一檔案或是在多個模組下, route 在 blueprint 也是全域變數, 也更彈性.

  • app/main/init.py
from flask import Blueprint

main = Blueprint('main', __name__)

from . import views, errors

應用程式的路由會存於 app/main/views.py, error handlers 會存於 app/main/errors.py, 值得一提的是, 以上兩個模組會在 app/init.py 下引入, 以避免循環的相依.

  • app/__init__.py

Blueprint registration

def create_app(config_name): 
    # ...

    from main import main as main_blueprint
    app.register_blueprint(main_blueprint)

    return app
  • app/main/errors.py

與單一檔案的不同點在於, 使用修飾子時, @main.app_errorhandler, 端看 blueprint 註冊在哪

from flask import render_template
from . import main


@main.app_errorhandler(404)
def page_not_found(e):
    return render_template('404.html'), 404


@main.app_errorhandler(500)
def internal_server_error(e):
    return render_template('500.html'), 500
  • app/main/views.py

與單一檔案的兩個相異處, 第一, @main.route; 第二, url_for('main.index')

from flask import render_template, session, redirect, url_for, current_app
from .. import db
from ..models import User
from ..email import send_email
from . import main
from .forms import NameForm


@main.route('/', methods=['GET', 'POST'])
def index():
    form = NameForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.name.data).first()
        if user is None:
            user = User(username=form.name.data)
            db.session.add(user)
            session['known'] = False
            if current_app.config['FLASKY_ADMIN']:
                send_email(current_app.config['FLASKY_ADMIN'], 'New User',
                           'mail/new_user', user=user)
        else:
            session['known'] = True
        session['name'] = form.name.data
        return redirect(url_for('.index'))
    return render_template('index.html',
                           form=form, name=session.get('name'),
                           known=session.get('known', False))

啟動指令 (Launch Script)

  • manage.py
  1. 如果 os 環境中有定義 FLASK_CONFIG, 則使用該對應的設定, 否則使用預設值
  2. 為方便起見, 宣告為可執行的 python 檔, #!/usr/bin/env python, 即可用 ./manage.py 來進行操作
#!/usr/bin/env python
import os
from app import create_app, db
from app.models import User, Role
from flask.ext.script import Manager, Shell
from flask.ext.migrate import Migrate, MigrateCommand

app = create_app(os.getenv('FLASK_CONFIG') or 'default')
manager = Manager(app)
migrate = Migrate(app, db)


def make_shell_context():
    return dict(app=app, db=db, User=User, Role=Role)
manager.add_command("shell", Shell(make_context=make_shell_context))
manager.add_command('db', MigrateCommand)


@manager.command
def test():
    """Run the unit tests."""
    import unittest
    tests = unittest.TestLoader().discover('tests')
    unittest.TextTestRunner(verbosity=2).run(tests)


if __name__ == '__main__':
    manager.run()

需求檔案 (Requirements File)

pip 可將虛擬環境中所使用的套件名稱以及版本匯出, 以利當應用程式遷移到新的 host 上, 可以重新佈署使用的套件

$ pip freeze
alembic==0.8.5
blinker==1.4
dominate==2.1.17
Flask==0.10.1
Flask-Bootstrap==3.3.5.7
Flask-Mail==0.9.1
Flask-Migrate==1.8.0
Flask-Moment==0.5.1
Flask-Script==2.0.5
Flask-SQLAlchemy==2.1
Flask-WTF==0.12
itsdangerous==0.24
Jinja2==2.8
Mako==1.0.4
MarkupSafe==0.23
python-editor==0.5
SQLAlchemy==1.0.12
visitor==0.1.2
Werkzeug==0.11.4
WTForms==2.1

輸出到 requirements.txt, 慣例使用的檔案名稱

$ pip freeze > requirements.txt

未來在新的 host 上, 只要建立隔離的虛擬環境後, 執行下列指令, 即可安裝之前用過的套件

$ pip install -r requirements.txt

Unit Tests (單元測試)

  • tests/test_basics.py
import unittest
from flask import current_app
from app import create_app, db


class BasicsTestCase(unittest.TestCase):
    def setUp(self):
        self.app = create_app('testing')
        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()

    def test_app_exists(self):
        self.assertFalse(current_app is None)

    def test_app_is_testing(self):
        self.assertTrue(current_app.config['TESTING'])

在單元測試中, 使用的是 Python 標準的 unittest 套件, 為了執行測試, 在 manage.py 中加入 test()

  • manage.py
@manager.command
def test():
    """Run the unit tests."""
    import unittest
    tests = unittest.TestLoader().discover('tests')
    unittest.TextTestRunner(verbosity=2).run(tests)

執行結果如下

$ python manage.py test
test_app_exists (test_basics.BasicsTestCase) ... ok
test_app_is_testing (test_basics.BasicsTestCase) ... ok

----------------------------------------------------------------------
Ran 2 tests in 0.073s

資料庫設定 (Database Setup)

在設定檔中, 可依據不同開發環境, 指定資料庫名稱, 或是資料庫類型, 建立資料庫遷移的指令

$ python manage.py db upgrade
Clone this wiki locally