第七章 大型程序架构
虽然在一个脚本里完成一个web应用很便利,但是这也意味着它很难扩展。当程序不断增长,越来越复杂,在一个巨大的源码文件中工作就会问题多多。不像其他一些web框架,Flask并没有规定大型程序应该以某种特定形式进行组织,程序结构完全由开发者自己决定。
本章介绍了一种可能的大型程序的组织方式:包和模块。这一结构将在下面书中的例子中应用。
工程结构
例子7-1显示了一个Flask程序的基本布局。
|--flasky/
requirements.txt
config.txt
manage.py
|-app/
__init__.py
email.py
modes.py
|-templates/
|-static/
|-main/
__init__.py
errors.py
forms.py
views.py
|-migrations/
|-tests/
|-venv/
这个结构有四个顶层文件夹:
- 整个应用程序被包含在一个包里面,该包一般名为app;
- migrations文件夹包含了数据库迁移脚本,前面提到过;
- 单元测试包含在tests包里;
- venv文件夹包含了Python 虚拟环境,如前所述;
这里面还有一些新增文件:
- requirements.txt 列出了依赖的包,这样当在别的机器上运行时可以方便、准确地创建虚拟环境。
- config.py 保存了全部的配置
- manage.py 启动程序和其他一些程序任务
为了帮你充分理解,下列章节展示了把hello.py转换到这一结构的步骤。
配置选项
程序通常需要多个配置项。最好的例子就是在开发、测试、投产不同阶段,需要使用不同的数据库,并确保这些数据库不相互干扰。
我们使用一个可继承的配置类取代了原来hello.py中简单地类似字典的配置结构。请看例子7-2,
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()类方法,以程序实例为参数。这样就可以执行特定配置的初始化。现在,基础的Config类只是实现了一个空方法。
在配置脚本的最后,不同的配置被注册为一个config字典。同时,其中的一个配置项(此处是开发模式)被指定为默认值。
译注:这里启动程序时,监视窗口中SQLAlchemy也会有一个提醒信息,要求你把
SQLALCHEMY_TRACK_MODIFICATIONS 设置为True。这个与新版(0.11.1)Flask的提醒一样,都是鉴于将来的版本变动准备的。我们需要在配置文件中添加一行就可以了。
Flasky/config.py文件中: SQLALCHEMY_TRACK_MODIFICATIONS=True
程序包
程序包是包含了所有程序代码、模板、和静态文件的集合。一般简单的称为 app,当然你也可以给它指定一个名字。templates和statics两个文件夹是app包的一部分,所以被移动到app文件夹里。数据模型和email函数也被移动到这个包里,每个都是独立的一个模块:app/models.py和app/email.py
使用应用工厂
在一个文件里创建应用程序非常方便,但也有缺点。因为程序是全局范围的,无法动态更改一些配置:在脚本运行时,程序实例已经被创建了,所以此时要更改配置就太晚了。而这一点在进行单元测试时尤其重要,因为我们必须在不同配置下运行程序才能有更好的测试覆盖率。
这一问题的解决方案就是延迟创建程序:把程序创建过程转到工厂函数中,从而可以在脚本中明确调用。这不但给了脚本设置配置的时间 ,也具备了创建多个程序实例的能力——在进行测试的时候非常重要。如例子7-3,应用程序工厂函数被定义在app包的构造函数里。
这个app包构造函数导入了当前使用的大部分Flask扩展,但因为此处还没有初始化程序实例,所以这些扩展的构造函数中都是空参数。create_app()函数就是应用工厂,它以程序使用的配置名作为参数。使用app.config对象的from_object()方法,可以直接把config.py的配置类中保存的配置导入到程序中,并从配置字典中通过名字选择配置对象。一旦程序创建并配置完成,扩展就可以初始化了。调用已创建的各扩展的init_app()完成各自的初始化。
例子7-3
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
工厂函数返回了创建的程序实例,但注意,工厂函数当前状态下创建的程序并没有完成,比如就没有路由、没有自定义错误处理页面。这是下一节的内容。
通过蓝图实现应用程序的功能
在应用程序转换到工厂模式过程中,路由配置上会存在问题。在单脚本程序中,程序实例是全局作用域可用的,所以路由可以使用app.route装饰器很轻松搞定。但是现在由于程序实例是在运行时创建的,而app.route装饰器只能在create_app()调用结束之后才开始作用——这就太晚了。错误处理的问题跟路由类似,因为它也是通过装饰器app.errorhandler来定义的。
幸运的是,Flask提供了名为“蓝图(blueprint)”的解决方案。蓝图类似于一个应用程序,可以在其中定义路由。不同之处是,与蓝图关联的路由处于休眠状态,直到蓝图被注册到程序,这时路由就变成了该程序的一部分。借助在全局作用域中定义的蓝图,就可以像在单脚本程序中那样定义程序的路由。
类似于单脚本程序,我们可以把全部蓝图定义在一个文件中,也可以以在一个包里以模块的形式定义多个蓝图。出于最大灵活性的考虑,我们在应用程序包(译注:app包
)中创建一个子包来管理这些蓝图。例子7-4显示了创建蓝图的包的结构。
Example 7-4. app/main/__init__.py: Blueprint creation
from flask import Blueprint
main = Blueprint('main', __name__)
from . import views, errors
蓝图是创建为 bulueprint类的一个实例化对象。这个类的构建函数有两个必备参数:蓝图名和模块或包(译注:蓝图所处的
)的名字。就像程序中那样,python的__name__
变量在大部分情况下都适合做第二个参数。
程序路由存在app/main/views.py
模块里,错误处理则存放在app/main/error.py
中。导入这些模块后,路由和错误处理就被关联到蓝图。有很重要的一点要注意:为了防止循环依赖,我们在app/__init__.py
脚本的底部才导入模块,因为views.py和errors.py也需要导入main蓝图。
蓝图在create_app()工厂函数里被注册到程序,如例子7-5:
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
例子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
在蓝图中编写错误处理时,需要注意有一点不同:如果像以前一样使用errorhandler装饰器,那么只有蓝图内发生的错误才会调用该错误处理器。要设置全程序范围的错误处理器,必须使用app_errorhandler装饰器。
例子7-7展示了更新为蓝图版的程序路由设计
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())
在蓝图中创建视图函数主要有两点不同。首先,就像前面错误处理器那样(@main.app_errorhandler),路由装饰器来自于蓝图(使用@main.route)。其二,url_for()用法不同了。
你可能记得,url_for()的第一个参数是路由的端点名(endpoint name),对于基于程序的路由默认就是对应的视图函数名。例如,在单脚本程序中,index()的视图函数的url可以通过url_for('index')获得。
蓝图的不同之处是,Flask为所有蓝图的端点提供了一个命名空间,这样多个蓝图可以使用同样的端点名来定义视图函数而不冲突。命名空间就是蓝图的名字(如main,蓝图构造函数的第一个参数),那么index()视图函数的端点名就被注册为main.index,其url就可以通过url_for('main.index')来取得。
url_for()函数也支持蓝图的短格式端点名(省略了蓝图名),如url_for('.index'),使用点号('.'),就可以使用蓝图函数处理当前请求。这样一来,在同一个蓝图内的重定向就可以使用短格式,而如果是跨蓝图的话,重定向还是必须加上命名空间名称(蓝图名)的。
为了完成更改程序页面,表单对象也同样被保存在蓝图下app/main/forms.py模块当中
启动脚本
在最顶层文件夹里的manage.py是用来启动程序的。这个脚本内容如例子7-8所示:
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,Flas-Migrate和自定义shell上下文陆续初始化。
为了方便,添加了一个位置行(#!/usr/bin/env python
指定了python解释器位置),这样在Unix类操作系统下,这个脚本可以通过./manage.py
启动,而不需要使用python manage.py
命令。
Requirements文件
程序必须包含一个requirements.txt文件,来记录所有的依赖包和确切版本。这可以帮你在别的机器上快速还原虚拟环境,比如当你在部署程序投入生产的时候,可以利用这个文件快速生成虚拟环境。这个文件可以通过pip命令自动生成:
(venv) $ pip freeze >requirements.txt
当你的包升级或新加之后,应该及时更新这个文件。下面是一个示例:
Flask==0.10.1
Flask-Bootstrap==3.0.3.1
Flask-Mail==0.9.0
Flask-Migrate==1.1.0
Flask-Moment==0.2.0
Flask-SQLAlchemy==1.0
Flask-Script==0.6.6
Flask-WTF==0.9.4
Jinja2==2.7.1
Mako==0.9.1
MarkupSafe==0.18
SQLAlchemy==0.8.4
WTForms==1.0.5
Werkzeug==0.9.4
alembic==0.6.2
blinker==1.3
itsdangerous==0.23
一旦需要创建一个虚拟环境的完美复本的时候,你可以先创建新虚拟环境,然后运行以下命令,所用到的包和依赖就会被完美复制:
(venv) $ pip install -r requirements.txt
此处你看到的文档里各包的版本可能已经过期了。你可以尝试使用比较新的版本。要是出现问题,那就回退到这个文档里标注的版本——它们肯定是兼容的。
译注:2016-7-20,现在全新安装虚拟环境的话,Flask已经升级到0.11.1了,会出现一些提醒信息,比如导入扩展的语法将要从
form flask.ext.moment import Moment
变更为from flask_moment import Moment
。
单元测试
程序现在很小,没有什么可测试的。但是,为了演示一下,我们还是在例子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标准库的unittest包写的。setUp()和tearDown()方法分别在每个测试开始之前和结束后运行,任何以test_
前缀开头的方法都会被当作测试来执行。
你可以阅读官方文档了解Python的unittest标准库。
setUp()方法试图为接近运行程序的测试创建环境。首先使用test配置创建程序,并激活其上下文。这一步是确保测试能够像普通请求一样访问current_app。然后,需要的话它就创建一个新的测试数据库。数据库和程序上下文将在tearDown()方法中被移除。
第一个测试是确定程序示例已经存在。第二个测试则确认程序以测试配置在运行。给tests文件夹添加一个文件tests/__init__.py
,把这个文件夹变成一个包。这个文件只是一个空白文件,其作用就是供unittests库在扫描所有模块时用以确定测试文件的位置。
为了运行单元测试,你需要向manage.py脚本添加一个自定义命令。例子7-10展示了如何添加命令test:
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装饰器就能方便得自定义命令。装饰器函数的名字就是被用作命令的名字,函数的文本字符("""Run the unit tests."""
)将被显示在帮助消息里。test()函数的实现则调用了unittest包的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数据库作备胎。环境变量名和SQLite数据库的文件名在三种环境配置下各自不同。例如,开发环境下,URL取自环境变量DEV_DATABASE_URL。如果没有定义的话,就使用data-dev.sqlite这一SQLite数据库备胎。
不论数据库URL是什么,只要是新数据库,都需要创建数据表。当你使用Flask-Migrate来管理迁移时,只需使用如下命令即可创建新数据表或更新其到最新版本:
(venv) $ python manage.py db upgrade
可能难以置信,现在你已经完成了本书第一部分了!目前,你已经学习了适用Flask开发web应用所需的各种扩展部件等——可能你还不太确定如何在真实程序中把它们组合在一起。下面本书第二部分的目标就是带领你开发一个完整的程序。
<<第六章 EMail 第八章 用户验证>>