《Flask Web Development》第7章-大型应用程序架构

把一个小应用程序的代码都放在一起会很方便,但是不利于扩展,尤其当项目开始变大时在一个文件中工作就会带来一些问题。不像其他框架,Flask应用程序没有特定的组织方式,选择权完全交给了使用者。本章会介绍一种按照包和模块来组织大型应用程序的方法,并会在本书剩余的章节都采用这种结构。

项目结构

Example 7-1展示了一个Flask应用程序的布局:

Example 7-1. Basic multiple-file Flask application structure

Example 7-1.png

顶级有四个文件夹,分别是:

  • Flask应用程序所在的包通常被命名为app
  • 数据库迁移相关的脚本被放置在migration
  • 单元测试写在在tests
  • venv包含了Python的虚拟环境

同样,增加了一些新的文件:

  • requirements.txt 列举了依赖的包方便在新的电脑中对虚拟环境快速进行配置
  • config.py 存储了应用程序的配置参数
  • manage.py 用于启动应用程序以及做一些其他任务

为了更好地理解这样的布局方式,后面的部分会介绍如何从一个只有hello.py的程序扩展到上图所示的结构。

配置选项

应用程序需要一些配置,比如对于开发、测试、产品会需要不同的数据库那样才不会相互影响。和单文件版本中在hello.py中写所有的配置不同,我们能够用类层级的方式来组织配置:

Example 7-2. config.py: Application configuration

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
    FLASKY_MAIL_SUBJECT_PREFIX = '[Flasky]'
    FLASKY_MAIL_SENDER = 'Flasky Admin <flasky@example.com>' 
    FLASKY_ADMIN = os.environ.get('FLASKY_ADMIN')

    @staticmethod
    def init_app(app): 
        pass
        
class DevelopmentConfig(Config): DEBUG = 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') 
    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基类包含了对所有配置通用的设置,不同的配置子类则定义了特有的设置。随需求变更还能增加其他配置子类。

为了让配置更灵活、安全,一些配置参数可以从环境变量中导入,比如SECRET_KEY考虑到安全性,可以存储在环境变量中,并且在配置脚本中提供了一个默认值以防环境变量没有设置它。

在三套不同的配置中,SQLALCHEMY_DATABASE_URI被赋予了不同的值,这样运行在三套不同配置下的应用程序都使用了不同的数据库。

配置类定义了类方法init_app(),它接受一个应用程序实例作为参数。这样特殊的配置就能够执行了(:原文是 Here configuration-specific initialization can performed 没明白init_app()这个方法跟特殊配置起不起作用有什么关系,至少在本章中的例子中没有体现出来)。当前,仅Config类实现了一个空的init_app()方法。

在配置文件的底部不同的配置被添加到了字典中,并且开发环境的配置被设置成了默认的。

应用程序包App

应用程序包app是所有应用程序代码、模板、静态资源文件存放的地方,当然你也可以根据项目需求取别的名字。模板和资源文件的文件夹都被放入了app中,数据库对应的models和邮件支持功能模块则分别对应 app/models.py 和 app/email.py。

使用工厂方法来构建应用示例

在单文件版本中创建应用程序实例很方便,但是通常会有缺陷。因为应用程序实例在全局作用于下被创建,而实例被创建后是没办法动态修改配置的。 尤其在做单元测试时,因为要跑不同的数据库,所以我们要应用不同的配置。

解决办法就是通过使用工厂方法延迟应用程序实例的创建,这样不仅仅是延迟了创建时间还让脚本有创建多个应用程序实例的能力,这对于测试尤其有用。Example 7-3中在app包中定义了了这样一个工厂方法。

app包导入了Flask目前会用到的扩展,但因为应用程序实例还没有被构建出来,它们都还没有被正确初始化。create_app()这个工厂方法接受一个配置名称作为参数,通过使用Flask提供的app.config的from_object()方法,我们就能从config.py中导入所需要的配置。一旦应用程序实例被创建出来,扩展就能够通过调用init_app()来完成初始化。

Example 7-3. app/__init__.py: Application package constructor

from flask import Flask, render_template 
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)
    # attach routes and custom error pages here

    return app

工厂方法返回的应用程序实例还不完整,因它们没有包含路由和错误处理功能,下一节会介绍如何解决这个问题。

使用Blueprint来实现应用程实例的功能

用工厂方法构建应用程序实例会给路由设置带来一些麻烦。单脚本应用中,应用程序实例是全局的,路由能简单地用app.route decorator来定义。但是现在应用程序实例是运行时创建的,app.route decorator只在在create_app()以后才存在,除此之外app.errorhandler decorator也有同样的问题。

Flask提供的解决方案是使用blueprints来解决这个问题。blueprints跟application类似,也能定义路由。不同之处是它的路由都处于休眠状态,直到它被注册到应用程序实例后路由才是它的一部分。

blueprint在全局作用域下使用,因此我们完全可以像在单文件中那样使用路由。当然你既能通过单文件也能通过更加组织良好的方式。为了达到最大程度的便利性,一个子包结构被创建用于管理blueprint。Example 7-4展示了在这个main包中如何创建blueprint:

Example 7-4. app/main/init.py: Blueprint creation

from flask import Blueprint
main = Blueprint('main', __name__) 
from . import views, errors

blueprints被创建为Blueprint的实例对象,构造函数有两个参数:blueprint的名字和它所在的模块或者包,在这个应用程序中,Python的 __name__ 变量就是第二个参数所需要的值。

应用程序的路由被存储在app/main/views.py模块中, 错误处理则在app/main/errors.py。导入这些模块以后,路由和错误处理就和blueprint关联起来了。

有一点要注意路由和错误处理模块是在app/__init__.py的底部被导入的,因为views.py 和 errors.py要导入main blueprint,所以为了避免循环依赖我们要等到main被创建出来才能够导入路由和错误处理。

如Example 7-5所示,blueprint在create_app()方法内被注册到应用程序实例中:

Example 7-5. app/__init__.py: Blueprint registration

def create_app(config_name): 
    # ...
    from main 
    import main as main_blueprint      
    app.register_blueprint(main_blueprint)
    return app

Example 7-6展现了错误处理:

Example 7-6. app/main/errors.py: Blueprint with error handlers

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

在blueprint使用错误处理,如果使用@app.errorhandler,只有由blueprint定义的路由中导致的错误才会触发对应的handler,如果想要错误处理对整个应用程序可用,我们需要使用@main.app_errorhandler。

Example 7-7展示了使用blueprint方式的路由:

Example 7-7. app/main/views.py: Blueprint with application routes

from datetime import datetime
from flask import render_template, session, redirect, url_for
from . import main
from .forms import NameForm 
from .. import db
from ..models import User

@main.route('/', methods=['GET', 'POST']) 
def index():
    form = NameForm()
    if form.validate_on_submit():
        # ...
        return redirect(url_for('.index')) 
    return render_template('index.html',
                           form=form, name=session.get('name'),
                           known=session.get('known', False),
                           current_time=datetime.utcnow())

在blueprint中使用视图方法跟之前有两个不同的地方。第一个是route是来自blueprint,即-使用@main.route,第二个是url_for()方法的使用。在前面介绍过url_for()的参数默认是视图方法的名称,比如在单脚本应用中index()这个视图方法的URL能够通过url_for('index')获取到。

在blueprints中区别在于所有的作用域都来自于blueprint(作用域就是blueprint的名称,即Blueprint构造函数的第一个参数),因此index()视图方法需要通过main.index来获取到URL,即url_for('main.index')。url_for()方法同样支持参数的更短形式,通过将blueprint名字省略,我们可以简写为url_for('.index')。当然如果跨越不同的blueprints,blueprint的名字还是要加上的。

为了完成应用程序,我们还需要在app/main/forms.py模块导入form相关的一些对象。

启动脚本

在顶层文件夹下的manage.py是用来启动application的:

Example 7-8. manage.py: Launch script

#!/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)

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

该脚本首先创建应用程序实例,然后从系统环境中读取FLASK_CONFIG变量,如果该变量没有定义则使用默认值。然后Flask-Script, Flask-Migrate等扩展的实例都被初始化。为了方便在Unix-based系统下运行我们增加了第一行。

Requirements文件

Applications应该包含一个requirements.txt,它记录了有着准确版本号的所有包依赖,这对以在其他电脑上初始化项目环境很重要。通过如下命令能够自动生成一个项目用到的包的requirement.txt文件:

(venv) $ pip freeze >requirements.txt

在一个新的环境中,你如果要复制虚拟环境中的安装包,只需要执行如下命令即可:

(venv) $ pip install -r requirements.txt

该书示例中的requirement.txt中的包可能有一些已经过时了,你可以选择更加新版的包。如果因此遇到了什么问题,只要回退到老版本即可,因为老版本的都是通过了测试和应用程序兼容的。

单元测试

到目前应用程序还很小,几乎还没有什么要测试的,但如Example 7-9所示我们先来写一个小的测试例子:

Example 7-9. tests/test_basics.py: Unit tests

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包中的典型的单元测试的写法来构建的,setUp() 和 tearDown() 方法在每个测试方法执行前后都会运行,任何以test_ 开头的方法都会被当做测试方法来执行。关于使用Python包来做单元测试的更多信息可以查看official documentation

setUp()方法创建了测试所需的环境, 他首先创建了应用程序实例用作测试的山下文环境,这样就能确保测试拿到current_app, 然后新建了一个全新的数据库。数据库和应用程序实例最后都会在tearDown() 方法被销毁。

第一个测试确保了应用程序实例是存在的,第二个测试应用程序实例在测试配置下运行。为了确保测试文件夹有正确的包结构,我们需要添加一个tests/__init__.py文件(:涉及Python包相关知识),这样单元测试包就能扫描所有在测试文件夹中的模块了。

你可以把代码checkout到7a的历史节点,并且执行 pip install -r requirements.txt 来确保你安装了所需要的包。为了运行测试用例,还需要添加命令到manage.py中:

Example 7-10. manage.py: Unit test launcher command

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

manager.command decorator所对应的方法名字就是命令的名字,并且方法的文档信息会被显示在help中,test() 的实现调用了unittest package包的test runner。如下是运行过程:

(venv) $ python manage.py test
test_app_exists (test_basics.BasicsTestCase) ... ok
test_app_is_testing (test_basics.BasicsTestCase) ... ok
.----------------------------------------------------------------------
Ran 2 tests in 0.001s
OK

数据库设置

重构后的应用程序使用了跟单文件本版本中完全不同的数据库。数据库URL会首先从环境变量中获取,然后把默认的SQLite数据库作为备选,在三个配置环境下数据库的名字是不同的。

不论数据库的URL是什么,只要是转换到一个新的数据库数,据库表一定要被重新创建(:原文Regardless of the source of the database URL, the database tables must be created for the new database 不完全理解)。使用Flask-Migrate进行迁移管理的过程中,数据库表能够通过如下命令被新建或者upgrade:

(venv) $ python manage.py db upgrade

第一部分的内容到此算是结束了,我们已经基本介绍了使用Flask来创建应用程序的所有知识,但是你也许仍旧不确定如何将他们捏合在一起。第二部分的目标就是帮助你完成一个应用程序的开发。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容

  • 22年12月更新:个人网站关停,如果仍旧对旧教程有兴趣参考 Github 的markdown内容[https://...
    tangyefei阅读 35,159评论 22 257
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,585评论 18 139
  • flask是python的一个web应用框架,django很多人听过,flask比较少见,连创始人一开始写出来只是...
    思而忧阅读 2,930评论 0 5
  • 第七章 大型程序架构 虽然在一个脚本里完成一个web应用很便利,但是这也意味着它很难扩展。当程序不断增长,越来越复...
    易木成华阅读 904评论 0 1
  • 这几天想学新东西,就看了flask框架,本身对python不太了解,网上的很多教程看了,总是在某些地方卡住。翻到一...
    易木成华阅读 2,216评论 0 11