本文翻译自The Flask Mega-Tutorial Part XV: A Better Application Structure

这是Flask Mega-Tutorial系列的第十五部分,我将使用适用于大型应用的风格重构本应用。

Microblog已经是一个初具规模的应用了,所以我认为这是讨论Flask应用如何在持续增长中不会变得混乱和难以管理的好时机。 Flask是一个框架,旨在让你选择以任何方式来组织项目,基于该理念,在应用日益庞大或者技能水平变化的时候,才有可能更改和调整其结构。

在本章中,我将讨论适用于大型应用的一些模式,并且为了演示他们,我将对Microblog项目的结构进行一些更改,目标是使代码更易于维护和组织。 当然,在真正的Flask精神中,我鼓励你在尝试决定组织自己的项目的方式时仅仅将这些更改作为参考。

本章的GitHub链接为:Browse, Zip, Diff.

目前的局限性

目前状态下的应用有两个基本问题。 如果你观察应用的组织方式,你会注意到有几个不同的子系统可以被识别,但支持它们的代码都混合在了一起,没有任何明确的界限。 我们来回顾一下这些子系统是什么:

  • 用户认证子系统,包括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。 展示更改的详细列表有些不切实际,因为几乎应用中每个文件都有少许变化,所以我将讨论重构的步骤,然后你可以下载更改后的应用。

Blueprints

在Flask中,blueprint是代表应用子集的逻辑结构。 blueprint可以包括路由,视图函数,表单,模板和静态文件等元素。 如果在单独的Python包中编写blueprint,那么你将拥有一个封装了应用特定功能的组件。

Blueprint的内容最初处于休眠状态。 为了关联这些元素,blueprint需要在应用中注册。 在注册过程中,需要将添加到blueprint中的所有元素传递给应用。 因此,你可以将blueprint视为应用功能的临时存储,以帮助组织代码。

错误处理Blueprint

我创建的第一个blueprint用于封装对错误处理程序的支持。 该blueprint的结构如下:

  1. app/
  2. errors/ <-- blueprint package
  3. __init__.py <-- blueprint creation
  4. handlers.py <-- error handlers
  5. templates/
  6. errors/ <-- error templates
  7. 404.html
  8. 500.html
  9. __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。

  1. from flask import Blueprint
  2. bp = Blueprint('errors', __name__)
  3. 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。

  1. app = Flask(__name__)
  2. # ...
  3. from app.errors import bp as errors_bp
  4. app.register_blueprint(errors_bp)
  5. # ...
  6. from app import routes, models # <-- remove errors from this import!

为了注册blueprint,将使用Flask应用实例的register_blueprint()方法。 在注册blueprint时,任何视图函数,模板,静态文件,错误处理程序等均连接到应用。 我将blueprint的导入放在app.register_blueprint()的上方,以避免循环依赖。

用户认证Blueprint

将应用的认证功能重构为blueprint的过程与错误处理程序的过程非常相似。 以下是重构为blueprint的目录层次结构:

  1. app/
  2. auth/ <-- blueprint package
  3. __init__.py <-- blueprint creation
  4. email.py <-- authentication emails
  5. forms.py <-- authentication forms
  6. routes.py <-- authentication routes
  7. templates/
  8. auth/ <-- blueprint templates
  9. login.html
  10. register.html
  11. reset_password_request.html
  12. reset_password.html
  13. __init__.py <-- blueprint registration

为了创建这个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到应用。

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

在这种情况下,register_blueprint()调用接收了一个额外的参数,url_prefix。 这完全是可选的,Flask提供了给blueprint的路由添加URL前缀的选项,因此blueprint中定义的任何路由都会在其完整URL中获取此前缀。 在许多情况下,这可以用来当成“命名空间”,它可以将blueprint中的所有路由与应用或其他blueprint中的其他路由分开。 对于用户认证,我认为让所有路由以/auth开头很不错,所以我添加了该前缀。 所以现在登录URL将会是http://localhost:5000/auth/login。 因为我使用url_for()来生成URL,所有URL都会自动合并前缀。

主应用Blueprint

第三个blueprint包含核心应用逻辑。 重构这个blueprint和前两个blueprint的过程一样。 我给这个blueprint命名为main,因此所有引用视图函数的url_for()调用都必须添加一个main.前缀。 鉴于这是应用的核心功能,我决定将模板留在原来的位置。 这不会有什么问题,因为我已将其他两个blueprint中的模板移动到子目录中了。

应用工厂模式

正如我在本章的介绍中所提到的,将应用设置为全局变量会引入一些复杂性,主要是以某些测试场景的局限性为形式。 在我介绍blueprint之前,应用必须是一个全局变量,因为所有的视图函数和错误处理程序都需要使用来自app的装饰器来修饰,比如@app.route。 但是现在所有的路由和错误处理程序都被转移到了blueprint中,因此保持应用全局性的理由就不够充分了。

所以我要做的是添加一个名为create_app()的函数来构造一个Flask应用实例,并消除全局变量。 转换并非容易,我不得不理清一些复杂的东西,但我们先来看看应用工厂函数:

app/init.py:应用工厂函数。

  1. # ...
  2. db = SQLAlchemy()
  3. migrate = Migrate()
  4. login = LoginManager()
  5. login.login_view = 'auth.login'
  6. login.login_message = _l('Please log in to access this page.')
  7. mail = Mail()
  8. bootstrap = Bootstrap()
  9. moment = Moment()
  10. babel = Babel()
  11. def create_app(config_class=Config):
  12. app = Flask(__name__)
  13. app.config.from_object(config_class)
  14. db.init_app(app)
  15. migrate.init_app(app, db)
  16. login.init_app(app)
  17. mail.init_app(app)
  18. bootstrap.init_app(app)
  19. moment.init_app(app)
  20. babel.init_app(app)
  21. # ... no changes to blueprint registration
  22. if not app.debug and not app.testing:
  23. # ... no changes to logging setup
  24. return app

你已经看到,大多数Flask插件都是通过创建插件实例并将应用作为参数传递来初始化的。 当应用不再作为全局变量时,有一种替代模式,插件分成两个阶段进行初始化。 插件实例首先像前面一样在全局范围内创建,但没有参数传递给它。 这会创建一个未附加到应用的插件实例。 当应用实例在工厂函数中创建时,必须在插件实例上调用init_app()方法,以将其绑定到现在已知的应用。

在初始化期间执行的其他任务保持不变,但会被移到工厂函数而不是在全局范围内。 这包括blueprint和日志配置的注册。 请注意,我在条件中添加了一个not app.testing子句,用于决定是否启用电子邮件和文件日志,以便在单元测试期间跳过所有这些日志记录。 由于在配置中TESTING变量在单元测试时会被设置为True,因此app.testing标志在运行单元测试时将变为True

那么谁来调用应用程工厂函数呢? 最明显使用此函数的地方是处于顶级目录的microblog.py脚本,它是唯一会将应用设置为全局变量的模块。 另一个调用该工厂函数的地方是tests.py,我将在下一节中更详细地讨论单元测试。

正如我上面提到的,大多数对app的引用都是随着blueprint的引入而消失的,但是我仍然需要解决代码中的一些问题。 例如,app/models.pyapp/translate.pyapp/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:将应用实例传递给另一个线程。

  1. from app import current_app
  2. def send_async_email(app, msg):
  3. with app.app_context():
  4. mail.send(msg)
  5. def send_email(subject, sender, recipients, text_body, html_body):
  6. msg = Message(subject, sender=sender, recipients=recipients)
  7. msg.body = text_body
  8. msg.html = html_body
  9. Thread(target=send_async_email,
  10. 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:注册自定义应用命令。

  1. import os
  2. import click
  3. def register(app):
  4. @app.cli.group()
  5. def translate():
  6. """Translation and localization commands."""
  7. pass
  8. @translate.command()
  9. @click.argument('lang')
  10. def init(lang):
  11. """Initialize a new language."""
  12. # ...
  13. @translate.command()
  14. def update():
  15. """Update all languages."""
  16. # ...
  17. @translate.command()
  18. def compile():
  19. """Compile all languages."""
  20. # ...

然后我从microblog.py中调用这个register()函数。 以下是完成重构后的microblog.py

microblog.py:重构后的主应用模块。

  1. from app import create_app, db, cli
  2. from app.models import User, Post
  3. app = create_app()
  4. cli.register(app)
  5. @app.shell_context_processor
  6. def make_shell_context():
  7. return {'db': db, 'User': User, 'Post' :Post}

单元测试的改进

正如我在本章开头所暗示的,到目前为止,我所做的很多工作都是为了改进单元测试工作流程。 在运行单元测试时,要确保应用的配置方式不会污染开发资源(如数据库)。

tests.py的当前版本采用了应用实例化之后修改配置的技巧,这是一种危险的做法,因为并不是所有类型的更改都会在修改之后才生效。 我想要的是有机会在添加到应用之前指定我想要的测试配置项。

create_app()函数现在接受一个配置类作为参数。 默认情况下,使用在config.py中定义的Config类,但现在我可以通过将新类传递给工厂函数来创建使用不同配置的应用实例。 下面是一个适用于我的单元测试的示例配置类:

tests.py:测试配置。

  1. from config import Config
  2. class TestConfig(Config):
  3. TESTING = True
  4. SQLALCHEMY_DATABASE_URI = 'sqlite://'

我在这里做的是创建应用的Config类的子类,并覆盖SQLAlchemy配置以使用内存SQLite数据库。 我还添加了一个TESTING属性,并设置为True,我目前不需要该属性,但如果应用需要确定它是否在单元测试下运行,它就派上用场了。

你一定还记得,我的单元测试依赖于setUp()tearDown()方法,它们由单元测试框架自动调用,以创建和销毁每次测试运行的环境。 我现在可以使用这两种方法为每个测试创建和销毁一个测试专用的应用:

tests.py:为每次测试创建一个应用。

  1. class UserModelCase(unittest.TestCase):
  2. def setUp(self):
  3. self.app = create_app(TestConfig)
  4. self.app_context = self.app.app_context()
  5. self.app_context.push()
  6. db.create_all()
  7. def tearDown(self):
  8. db.session.remove()
  9. db.drop_all()
  10. 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命令会自动激活应用程序上下文以方便使用。

  1. >>> from flask import current_app
  2. >>> current_app.config['SQLALCHEMY_DATABASE_URI']
  3. Traceback (most recent call last):
  4. ...
  5. RuntimeError: Working outside of application context.
  6. >>> from app import create_app
  7. >>> app = create_app()
  8. >>> app.app_context().push()
  9. >>> current_app.config['SQLALCHEMY_DATABASE_URI']
  10. 'sqlite:////home/miguel/microblog/app.db'

这就是秘密所在! 在调用你的视图函数之前,Flask推送一个应用上下文,它会使current_appg生效。 当请求完成时,上下文将与这些变量一起被删除。 为了使db.create_all()调用在单元测试setUp()方法中工作,我为刚刚创建的应用程序实例推送了一个应用上下文,这样db.create_all()可以使用 current_app.config知道数据库在哪里。 然后在tearDown()方法中,我弹出上下文以将所有内容重置为干净状态。

你还应该知道,应用上下文是Flask使用的两种上下文之一,还有一个请求上下文,它更具体,因为它适用于请求。 在处理请求之前激活请求上下文时,Flask的requestsession以及Flask-Login的current_user变量才会变成可用状态。

环境变量

正如构建此应用时你所看到的,在启动服务器之前,有许多配置选项取决于在环境中设置的变量。 这包括密钥、电子邮件服务器信息、数据库URL和Microsoft Translator API key。 你可能会和我一样觉得,这很不方便,因为每次打开新的终端会话时,都需要重新设置这些变量。

译者注:可以通过将环境变量设置到开机启动中,来保持它们在该计算机中的所有终端中都生效。

应用依赖大量环境变量的常见处理模式是将这些变量存储在应用根目录中的.env文件中。 应用在启动时会从此文件中导入变量,这样就不需要你手动设置这些变量了。

有一个支持.env文件的Python包,名为python-dotenv。 所以让我们安装这个包:

  1. (venv) $ pip install python-dotenv

由于config.py模块是我读取所有环境变量的地方,因此我将在创建Config类之前导入.env文件,以便在构造类时设置变量:

config.py:导入.env文件中的环境变量。

  1. import os
  2. from dotenv import load_dotenv
  3. basedir = os.path.abspath(os.path.dirname(__file__))
  4. load_dotenv(os.path.join(basedir, '.env'))
  5. class Config(object):
  6. # ...

现在你可以创建一个.env文件并在其中写入应用所需的所有环境变量了。不要将.env文件加入到源代码版本控制中,这非常重要。否则,一旦你的密码和其他重要信息上传到远程代码库中后,你就会后悔莫及。

.env文件可以用于所有配置变量,但是不能用于Flask命令行的FLASK_APPFLASK_DEBUG环境变量,因为它们在应用启动的早期(应用实例和配置对象存在之前)就被使用了。

以下示例显示了.env文件,该文件定义了一个安全密钥,将电子邮件配置为在本地运行的邮件服务器的25端口上,并且不进行身份验证,设置Microsoft Translator API key,使用数据库配置的默认值:

  1. SECRET_KEY=a-really-long-and-unique-key-that-nobody-knows
  2. MAIL_SERVER=localhost
  3. MAIL_PORT=25
  4. MS_TRANSLATOR_KEY=<your-translator-key-here>

依赖文件

此时我已经在Python虚拟环境中安装了一定数量的软件包。 如果你需要在另一台机器上重新生成你的环境,将无法记住你必须安装哪些软件包,所以一般公认的做法是在项目的根目录中写一个requirements.txt文件,列出所有依赖的包及其版本。 生成这个列表实际上很简单:

  1. (venv) $ pip freeze > requirements.txt

pip freeze命令将安装在虚拟环境中的所有软件包以正确的格式输入到requirements.txt文件中。 现在,如果你需要在另一台计算机上创建相同的虚拟环境,无需逐个安装软件包,可以直接运行一条命令实现:

  1. (venv) $ pip install -r requirements.txt