20.flask博客项目实战十五之蓝图

配套视频教程

本文B站配套视频教程

本章将使用适合大型应用程序的样式重新构建应用程序。

目前Microblog已是一个体面的应用程序了,所以此刻是讨论一个Flask应用程序如何成长而不会变得混乱或太难管理的时机了。Flask是一个框架,旨在为我们提供以想要的任何方式组织项目的选项,并且作为这个理念的一部分,它可以在应用程序变得更大、或我们的需要以及经验水平提升时,更改或调整应用程序的结构。

接下来,将讨论适用于大型应用程序的一些模式,并演示对Microblog项目的结构进行一些更改,目标是使代码更易于维护和更有条理。但是当然,在真正的Flask精神中,鼓励尝试决定组织自己的项目时,将这些更改作为建议。

目前的限制

应用程序在目前为止存在两个基本问题。看一下应用程序的结构,会注意到有一些不同的子系统可识别,但支持它们的代码是混合的,没有任何明确的界限。回顾一下这些子系统是什么:

  • 用户身份验证子系统,包括app/routes.py中的一些视图功能,app/forms.py中的一些表单,app/templates中的一些模板,以及app/email.py中的电子邮件支持。
  • 错误子系统,app/errors.py中定义的错误处理 和app/templates中的模板。
  • 核心应用程序功能,包括显示和编写博客帖子,用户个人资料和关注,以及博客帖子的实时翻译,这些功能通过大多数应用程序的模块 和模板进行传播。

考虑已确定的这个三个子系统以及它们的结构,可能会注意到一种模式。到目前为止,一直遵循的组织逻辑是基于具有专用于不同应用程序功能的模块。有一个视图函数模块、一个用于Web表单、一个用于错误处理、一个用于电子邮件,一个用于HTML模板,等等。虽然这是一个对于小项目有意义的结构,但是一旦项目开始增长,它往往会使这些模块中的一些变得非常庞大和混乱。

清楚地看到问题的一种方法是考虑如何通过尽可能多地重用这个项目来启动第二个项目。例如,用户身份验证部分 应该可以在其他应用程序中正常运行,但如果想要按原样使用这个代码,则必须进入多个模块,并将相关部分复制、粘贴到新项目的新文件中。这将是极其不方便的。如果这个项目将所有与身份验证相关的文件与应用程序的其余部分分开,这就非常好了。Flask蓝图功能有助于实现更实用的组织方式,使重用代码变得更容易。

第二个问题不明显。Flask应用程序实例 在app/init.py中以一个全局变量被创建,然后被很多应用程序模块所导入。虽然这本身不是一个问题,但将应用程序作为全局变量可能会使得某些场景复杂化,特别是与测试相关的场景中。想象一下,若想在不同配置下测试这个应用程序。由于应用程序被定义为全局变量,因此实际上无法实例化使用不同配置变量的两个应用程序。另一种不理想的情况是 所有测试都使用相同的应用程序,因此测试可能会对应用程序进行更改,从而影响以后运行的另一个测试。理想情况下,希望所有测试都在一个质朴的应用程序实例上运行。

实际上在tests.py模块中可看到,在应用程序中设置后要求修改配置,以指示测试使用内存数据库而不是基于磁盘的默认SQLite数据库。我们真的没有其他办法去更改已配置的数据库,因为在测试开始时,已经创建并配置了应用程序。对于这种特殊情况,在应用程序运用于应用程序后去更改配置似乎工作正常,但在其他情况下可能不会,并且在任何情况下,这都是一种不好的做法,可能导致模糊和难以发现的错误。

更好的解决方案是 不让应用程序使用全局变量,而是使用一个应用程序工厂函数在运行时去创建函数。这将是一个接受配置对象作为参数的函数,并返回一个设置了这些配置的Flask应用程序实例。如果我可以修改应用程序以使用应用程序工厂函数,那么编写需要特殊配置的测试讲变得容易,因为每个测试都可以创建自己的应用程序。

在本章中,我将重构应用程序,为上面提到的三个子系统和应用程序工厂函数引入蓝图。向大家显示更改的详细列表是不切实际的,因为属于应用程序的一部分的每个文件几乎没有变化,所以我将讨论重构所采取的的步骤。可在源码文件中查看这些更改。

蓝图

Flask中,蓝图是表示应用程序子集的逻辑结构。蓝图可包括路由、视图函数、表单、模板、静态文件等等元素。如果我们在单独的Python包中编写蓝图,那么我们将拥有一个组件,这个组件封装了与应用程序的特定功能相关的元素。

一个蓝图的内容最初是处于休眠状态。要关联这些元素,需要在应用程序中注册蓝图。在注册期间,添加到蓝图的所有元素都会传递给应用程序。因此,可将蓝图视为应用程序功能的临时存储,以帮助组织代码。

错误处理蓝图

创建的第一个蓝图是封装对错误处理的支持的蓝图。这个蓝图结构如下:

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.py模块 移动到app/errors/handlers.py中;将这两个错误模板移动到app/templates/errors中,以便它们与其他模板分开。还必须更改在两个错误处理中的render_template()调用,以使用的新的错误模板子目录。之后,在创建应用程序实例后,将蓝图创建添加到app/errors/init.py模块中,并将蓝图注册添加到app/init.py中。

应该注意到,可将Flask蓝图配置为具有模板或静态文件的单独目录。我已决定将模板移动到应用程序模板目录的子目录中,以便所有模板都在单个层次结构中,但如果希望在蓝图包中具有属于蓝图的模板,那是支持的。例如,如果向Blueprint()构造函数添加一个template_folder='templates'参数,那么可将蓝图的模板存储在app/errors/templates中。

创建蓝图与创建应用程序非常相似。这是在蓝图包的__init__.py模块中完成的:

app/errors/init.py:错误蓝图

from flask import Blueprint

bp = Blueprint('errors', __name__)

from app.errors import handlers

Blueprint类 采取蓝图的名字,基础模块的名字(如同在Flask应用程序实例中通常设置__name__),以及一些可选参数(在这种情况下,我不需要)。创建蓝图对象后,我导入handlers.py模块,以便其中的错误处理程序注册到蓝图。这个导入位于底部以避免循环依赖。

handlers.py模块中,我没有使用@app.errorhandler装饰器将错误处理程序附加到应用程序中,而是使用蓝图的@bp.app_errorhandler装饰器。虽然两个装饰器都达到了相同的最终结果,但我们的想法是尝试 使蓝图独立于应用程序,以便它更具可移植性。还需要修改两个错误模板的路径,以便考虑移动它们的新错误子目录。

app/errors/handlers.py

from flask import render_template
from app import db
from app.errors import bp

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

@bp.app_errorhandler(505)
def internal_error(error):
	db.session.rollback()
	return render_template('errors/500.html'), 500

完成错误处理程序重构的最后一步是 在应用程序中注册蓝图:

app/init.py:在应用程序中注册错误蓝图

#...
def create_app(config_class=Config):
	#...
	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!

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

身份验证蓝图

将应用程序的身份验证功能重构为一个蓝图的过程 与上一小节错误处理程序的过程非常相似。如下是重构蓝图的结构图:

<pre><code>
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
</code></pre>

要创建这个蓝图,必须将所有与身份验证相关的功能移动到 我在蓝图创建的新模块中。包括一些视图函数、Web表单、以及通过电子邮件发送密码重置令牌的支持函数等。还将模板移动到子目录中,以将它们与应用程序的其余部分分开。

在一个蓝图中定义路由时,得使用@bp.route装饰,而不是@app.route。在url_for()去构建URL时,使用的语法也需要做出更改。对于直接附加到应用程序的常规视图函数,url_for()的第一个参数是视图函数的名字。在一个蓝图中当定义一个路由时,这个参数必须包含蓝图名字和视图函数名字,并以逗号隔开。例如,我必须用url_for('auth.login')替换所有出现的url_for('login'),并且对剩下的视图函数也是如此。

在应用程序中注册 auth蓝图,使用了稍有不同的格式:

app/init.py

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

在这种情形下的register_blueprint()调用有一个额外的参数 url_prefix。这完全是可选的,但是Flask为我们提供了在URL前缀下附加蓝图的选项,因此任何在蓝图中定义的路由都会在它们的URL中取得前缀。在很多情况下,这可用作一种“命名空间”,它可将蓝图中的所有路由与应用程序或其他蓝图中的其他路由分开。对于身份验证,我认为让所有路由以 /auth开头很好,所有就添加了此前缀。所以现在登录URL将是 http://localhost:5000/auth/login。因为用url_for()生成URL,所以所有URL都会自动包含前缀。

app/auth/init.py

from flask import Blueprint

bp = Blueprint('auth', __name__)

from app.auth import routes

app/auth/email.py

from flask import render_template, current_app
from flask_babel import _
from app.email import send_email

def send_password_reset_email(user):
    token = user.get_reset_password_token()
    send_email(_('[Microblog] Reset Your Password'),
               sender=current_app.config['ADMINS'][0],
               recipients=[user.email],
               text_body=render_template('email/reset_password.txt',
                                         user=user, token=token),
               html_body=render_template('email/reset_password.html',
                                         user=user, token=token))

app/auth/forms.py

from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
from flask_babel import _, lazy_gettext as _l
from app.models import User

class LoginForm(FlaskForm):
    username = StringField(_l('Username'), validators=[DataRequired()])
    password = PasswordField(_l('Password'), validators=[DataRequired()])
    remember_me = BooleanField(_l('Remember Me'))
    submit = SubmitField(_l('Sign In'))

class RegistrationForm(FlaskForm):
    username = StringField(_l('Username'), validators=[DataRequired()])
    email = StringField(_l('Email'), validators=[DataRequired(), Email()])
    password = PasswordField(_l('Password'), validators=[DataRequired()])
    password2 = PasswordField(
        _l('Repeat Password'), validators=[DataRequired(),
                                           EqualTo('password')])
    submit = SubmitField(_l('Register'))

    def validate_username(self, username):
        user = User.query.filter_by(username=username.data).first()
        if user is not None:
            raise ValidationError(_('Please use a different username.'))

    def validate_email(self, email):
        user = User.query.filter_by(email=email.data).first()
        if user is not None:
            raise ValidationError(_('Please use a different email address.'))

class ResetPasswordRequestForm(FlaskForm):
    email = StringField(_l('Email'), validators=[DataRequired(), Email()])
    submit = SubmitField(_l('Request Password Reset'))

class ResetPasswordForm(FlaskForm):
    password = PasswordField(_l('Password'), validators=[DataRequired()])
    password2 = PasswordField(
        _l('Repeat Password'), validators=[DataRequired(),
                                           EqualTo('password')])
    submit = SubmitField(_l('Request Password Reset'))

app/auth/routes.py

from flask import render_template, redirect, url_for, flash, request
from werkzeug.urls import url_parse
from flask_login import login_user, logout_user, current_user
from flask_babel import _
from app import db
from app.auth import bp
from app.auth.forms import LoginForm, RegistrationForm, \
    ResetPasswordRequestForm, ResetPasswordForm
from app.models import User
from app.auth.email import send_password_reset_email

@bp.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))
    form = LoginForm()
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).first()
        if user is None or not user.check_password(form.password.data):
            flash(_('Invalid username or password'))
            return redirect(url_for('auth.login'))
        login_user(user, remember=form.remember_me.data)
        next_page = request.args.get('next')
        if not next_page or url_parse(next_page).netloc != '':
            next_page = url_for('main.index')
        return redirect(next_page)
    return render_template('auth/login.html', title=_('Sign In'), form=form)

@bp.route('/logout')
def logout():
    logout_user()
    return redirect(url_for('main.index'))

@bp.route('/register', methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))
    form = RegistrationForm()
    if form.validate_on_submit():
        user = User(username=form.username.data, email=form.email.data)
        user.set_password(form.password.data)
        db.session.add(user)
        db.session.commit()
        flash(_('Congratulations, you are now a registered user!'))
        return redirect(url_for('auth.login'))
    return render_template('auth/register.html', title=_('Register'),
                           form=form)

@bp.route('/reset_password_request', methods=['GET', 'POST'])
def reset_password_request():
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))
    form = ResetPasswordRequestForm()
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user:
            send_password_reset_email(user)
        flash(
            _('Check your email for the instructions to reset your password'))
        return redirect(url_for('auth.login'))
    return render_template('auth/reset_password_request.html',
                           title=_('Reset Password'), form=form)

@bp.route('/reset_password/<token>', methods=['GET', 'POST'])
def reset_password(token):
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))
    user = User.verify_reset_password_token(token)
    if not user:
        return redirect(url_for('main.index'))
    form = ResetPasswordForm()
    if form.validate_on_submit():
        user.set_password(form.password.data)
        db.session.commit()
        flash(_('Your password has been reset.'))
        return redirect(url_for('auth.login'))
    return render_template('auth/reset_password.html', form=form)

以下是模板:

app/templates/auth/login.html

{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}

{% block app_content %}
    <h1>{{ _('Sign In') }}</h1>
    <div class="row">
        <div class="col-md-4">
            {{ wtf.quick_form(form) }}
        </div>
    </div>
    <br>

    <p>{{ _('New User?') }} <a href="{{ url_for('auth.register') }}">{{ _('Click to Register!') }}</a></p>
    <p>
        {{ _('Forgot Your Password?') }}
        <a href="{{ url_for('auth.reset_password_request') }}">{{ _('Click to Reset It') }}</a>
    </p>
{% endblock %}

app/templates/auth/register.html

{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}

{% block app_content %}
	<h1>{{ _('Register') }}</h1>
	<div class="row">
		<div class="col-md-4">
			{{ wtf.quick_form(form) }}
		</div>
	</div>
{% endblock %}

app/templates/auth/reset_password.html

{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}

{% block app_content %}
	<h1>{{ _('Reset Your Password') }}</h1>
	<div class="row">
		<div class="col-md-4">
			{{ wtf.quick_form(form) }}
		</div>
	</div>
{% endblock %}

app/templates/auth/reset_password_request.html

{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}

{% block app_content %}
	<h1>{{ _('Reset Password') }}</h1>
	<div class="row">
		<div class="col-md-4">
			{{ wtf.quick_form(form) }}
		</div>
	</div>
{% endblock %}

应用程序【主main】蓝图

第三个蓝图 包含核心应用程序的逻辑。重构这个蓝图需要使用与前两个蓝图相同的过程。给这个蓝图取名为main,所以引用视图函数的所有url_for()调用都必须得到一个main.的前缀。鉴于这是应用程序的核心功能,我决定将模板保留在相同的位置。

app/main/init.py

from flask import Blueprint

bp = Blueprint('main', __name__)

from app.main import routes

app/main/forms.py

from flask import request
from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField
from wtforms.validators import ValidationError, DataRequired, Length
from flask_babel import _, lazy_gettext as _l
from app.models import User

class EditProfileForm(FlaskForm):
    username = StringField(_l('Username'), validators=[DataRequired()])
    about_me = TextAreaField(_l('About me'),
                             validators=[Length(min=0, max=140)])
    submit = SubmitField(_l('Submit'))

    def __init__(self, original_username, *args, **kwargs):
        super(EditProfileForm, self).__init__(*args, **kwargs)
        self.original_username = original_username

    def validate_username(self, username):
        if username.data != self.original_username:
            user = User.query.filter_by(username=self.username.data).first()
            if user is not None:
                raise ValidationError(_('Please use a different username.'))

class PostForm(FlaskForm):
    post = TextAreaField(_l('Say something'), validators=[DataRequired()])
    submit = SubmitField(_l('Submit'))

app/main/routes.py

from datetime import datetime
from flask import render_template, flash, redirect, url_for, request, g, \
    jsonify, current_app
from flask_login import current_user, login_required
from flask_babel import _, get_locale
from guess_language import guess_language
from app import db
from app.main.forms import EditProfileForm, PostForm
from app.models import User, Post
from app.translate import translate
from app.main import bp

@bp.before_app_request
def before_request():
    if current_user.is_authenticated:
        current_user.last_seen = datetime.utcnow()
        db.session.commit()
    #g.locale = str(get_locale())
    g.locale = 'zh' if str(get_locale()).startswith('zh') else str(get_locale())

@bp.route('/', methods=['GET', 'POST'])
@bp.route('/index', methods=['GET', 'POST'])
@login_required
def index():
    form = PostForm()
    if form.validate_on_submit():
        language = guess_language(form.post.data)
        if language == 'UNKNOWN' or len(language) > 5:
            language = ''
        post = Post(body=form.post.data, author=current_user,
                    language=language)
        db.session.add(post)
        db.session.commit()
        flash(_('Your post is now live!'))
        return redirect(url_for('main.index'))
    page = request.args.get('page', 1, type=int)
    posts = current_user.followed_posts().paginate(
        page, current_app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('main.index', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('main.index', page=posts.prev_num) \
        if posts.has_prev else None
    return render_template('index.html', title=_('Home'), form=form,
                           posts=posts.items, next_url=next_url,
                           prev_url=prev_url)

@bp.route('/explore')
@login_required
def explore():
    page = request.args.get('page', 1, type=int)
    posts = Post.query.order_by(Post.timestamp.desc()).paginate(
        page, current_app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('main.explore', page=posts.next_num) \
        if posts.has_next else None
    prev_url = url_for('main.explore', page=posts.prev_num) \
        if posts.has_prev else None
    return render_template('index.html', title=_('Explore'),
                           posts=posts.items, next_url=next_url,
                           prev_url=prev_url)

@bp.route('/user/<username>')
@login_required
def user(username):
    user = User.query.filter_by(username=username).first_or_404()
    page = request.args.get('page', 1, type=int)
    posts = user.posts.order_by(Post.timestamp.desc()).paginate(page, current_app.config['POSTS_PER_PAGE'], False)
    next_url = url_for('main.user', username=user.username, page=posts.next_num) if posts.has_next else None
    prev_url = url_for('main.user', username=user.username, page=posts.prev_num) if posts.has_prev else None
    return render_template('user.html', user=user, posts=posts.items, next_url=next_url, prev_url=prev_url)

@bp.route('/edit_profile',methods=['GET','POST'])
@login_required
def edit_profile():
    form = EditProfileForm(current_user.username)
    if form.validate_on_submit():
        current_user.username = form.username.data
        current_user.about_me = form.about_me.data
        db.session.commit()

        flash(_('Your changes have been saved.'))
        return redirect(url_for('main.edit_profile'))
    elif request.method == 'GET':
        form.username.data = current_user.username
        form.about_me.data = current_user.about_me
    return render_template('edit_profile.html', title=_('Edit Profile'), form=form)

@bp.route('/follow/<username>')
@login_required
def follow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash(_('User %(username)s not found.', username=username))
        return redirect(url_for('main.index'))
    if user == current_user:
        flash(_('You connot follow yourself!'))
        return redirect(url_for('main.user', username=username))
    current_user.follow(user)
    db.session.commit()
    flash(_('You are following %(username)s!', username=username))
    return redirect(url_for('main.user', username=username))

@bp.route('/unfollow/<username>')
@login_required
def unfollow(username):
    user = User.query.filter_by(username=username).first()
    if user is None:
        flash(_('User %(username)s not found.', username=username))
        return redirect(url_for('main.index'))
    if user == current_user:
        flash(_('You cannot unfollow yourself!'))
        return redirect(url_for('main.user', username=username))
    current_user.unfollow(user)
    db.session.commit()
    flash(_('You are not following %(username)s.', username=username))
    return redirect(url_for('main.user', username=username))

@bp.route('/translate', methods=['POST'])
@login_required
def translate_text():
    return jsonify(
        {'text': translate(request.form['text'], request.form['source_language'], request.form['dest_language'])})

应用工厂模式

正如在本章的介绍中提到的那样,将应用程序作为一个全局变量引入了一些复杂性,主要是对某些测试场景的限制形式。在引入蓝图之前,应用程序必须是一个全局变量,因为所有的视图函数 和错误处理程序都需要用来自app的装饰器进行装饰,例如@app.route。但是现在所有路由和错误处理程序都被移到了蓝图上,使应用程序保持全局的原因要少得多。

因此,我要做的是添加一个名为create_app()的函数,它构造了Flask应用程序实例,并消除全局变量。转换不是微不足道的,我不得不不解决一些复杂问题,但让我们首先看一下应用程序工厂函数:

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


import logging
from logging.handlers import SMTPHandler, RotatingFileHandler
import os

from flask import Flask, request
from flask_sqlalchemy import SQLAlchemy  # 从包中导入类
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_mail import Mail
from flask_bootstrap import Bootstrap
from flask_moment import Moment
from flask_babel import Babel, lazy_gettext as _l
from config import Config  # 从config模块导入Config类
from flask import current_app

db = SQLAlchemy()
migrate = Migrate()
login = LoginManager()  # 初始化Flask-Login
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)

    from app.errors import bp as errors_bp
    app.register_blueprint(errors_bp)

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

    from app.main import bp as main_bp
    app.register_blueprint(main_bp)


    if not app.debug and not app.testing:
        if not os.path.exists('logs'):
            os.mkdir('logs')
        file_handler = RotatingFileHandler('logs/microblog.log', maxBytes=10240, backupCount=10)
        file_handler.setFormatter(
            logging.Formatter('%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
        file_handler.setLevel(logging.INFO)
        app.logger.addHandler(file_handler)

        app.logger.setLevel(logging.INFO)
        app.logger.info('Microblog startup')
    return app


@babel.localeselector
def get_locale():
    return request.accept_languages.best_match(current_app.config['LANGUAGES'])



from app import models  # 在此移除errors、routes的导入

如上可看到,大多数Flask扩展都是通过创建扩展的实例并将应用程序作为参数传递来初始化的。当应用程序不作为全局变量存在时,有一种替代模式,其中扩展在两个阶段中初始化。扩展实例首先在全局范围内创建,但不会传递任何参数。这将创建未附加到应用程序的扩展实例。在工厂函数中创建应用程序实例时,必须在扩展实例上调用init_app()方法以将其绑定到现在已知的应用程序。

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

那么谁调用了应用程序工程函数?使用这个函数的显而易见的地方是顶级目录下的microblog.py脚本,它是应用程序现在在全局范围内存在的唯一模块。另一个地方是tests.py,将在下一节中更详细地讨论单元测试。

正如上面提到的,大多数引用app随着蓝图的引入而消失,但是我们必须解决一些代码中的问题。例如,app/models.py,app/translate.py和app/main/routes.py模块都有引用app.config。幸运的是,Flask开发人员试图让视图函数轻松访问应用程序实例,而不必像以前那样导入它。Flask提供的current_app变量是一个特殊的“上下文”变量,Flask在调度请求之前使用应用程序初始化这个变量。在之前已经看过另一个上下文变量,即我正在存储当前区域设置的g变量。这两个,以及Flask-Login的current_user,还有一些我们没看到的,有点“神奇”的变量。因为它们像全局变量一样工作,但只能在处理请求时访问,并且只能在处理它的线程中访问。

替换Flask的current_app变量的app消除了将应用程序实例作为全局变量导入的需要。通过简单的搜索和替换,没有任何困难,我能改变所有引用app.configcurrent_app.config

app/email.py模块呈现一个稍大的挑战,所以我不得不用一个小窍门:

app/email.py:将应用程序实例传递给另一个线程

from threading import Thread
from flask import current_app
from flask_mail import Message
from app import mail

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


send_email()函数中,应用程序实例作为参数传递给后台线程,然后后台线程将在不阻止应用程序的情况下传递电子邮件。直接在send_async_email()作为后台线程运行的函数中使用current_app将不起作用,因为current_app是一个与处理客户机请求的线程相关联的上下文感知变量。在不同的线程中,current_app不会分配值。直接将current_app作为参数传递给线程也不会有效,因为current_app实际上是一个动态映射到应用程序实例的代理对象。因此传递代理对象与使用current_app相同直接在线程中。我们需要做的是访问存储在代理对象中的真实应用程序实例,并将其作为app参数传递。current_app._get_current_object()表达式从代理对象中提取的实际应用程序实例,所以这就是我传递给线程作为参数。

当然还得修改app/templates/email/的reset_password.text和reset_password.html文件中的url_for('auth.reset_password',token=token,_external=True)添加前缀auth.

另一棘手的模块是 app/cli.py,它实现了一些用于管理语言翻译的快捷命令。在这种情况下,current_app变量不起作用,因为这些命令是在启动时注册的,而不是在处理请求期间注册的,这是唯一当current_app能够使用的时间。为了删除在这个模块中引用向app,我采用了一个技巧,即 将这些自定义命令移动以app实例作为参数的register()函数中:

app/cli.py:注册自定义应用程序命令

import os
import click

def register(app):
	@app.cli.group()
	def translate():
		#翻译和本地化命令
		pass

	@translate.command()
	@click.argument('lang')
	def init(lang):
		#初始化一个新语言
		if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'):
			raise RuntimeError('extract command failed')
		if os.system('pybabel init -i messages.pot -d app/translations -l ' +lang):
			raise RuntimeError('init command failed')
		os.remove('messages.pot')

	@translate.command()
	def update():
		#更新所有语言
		if os.system('pybabel extract -F babel.cfg -k _l -o messages.pot .'):
			raise RuntimeError('extract command failed')
		if os.system('pybabel update -i messages.pot -d app/translations'):
			raise RuntimeError('update command failed')
		os.remove('messages.pot')

	@translate.command()
	def compile():
		#编译所有语言
		if os.system('pybabel compile -d app/translations'):
			raise RuntimeError('compile command failed')


然后从microblog.py中调用这个register()函数。这是在所有重构后的完整microblog.py

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}

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

最后,app/models.py保持不变动。

单元测试改进

正如本章开头所暗示的那样,到目前为止,所做的很多工作都是为了改进 单元测试工作流程。在运行单元测试时,希望确保应用程序的配置方式不会干扰开发资源,例如 数据库。

当前版本的tests.py在将应用程序运用于应用程序实例之后 修改配置,这是一种危险的做法,因为并非所有类型的更改都可以在最后完成时运行。我想要的是 有机会在将测试配置添加到应用程序之前指定它。

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

#...
from app.models import User,Post
from config import Config

class TestConfig(config):
	TESTING = True
	SQLALCHEMY_DATABASE_URI = 'sqlite://'
#...

上述代码中 创建了应用程序的Config类的子类,并重写SQLAlchemy配置以使用内存中的SQLite数据库。还添加了一个TESTING属性集为True,目前不需要它,但如果应用程序需要确定它是否在单元测试下运行,则可能会很有用。

应该还记得,我们的单元测试依赖于单元测试框架自动调用的setUp()tearDown()方法来创建和销毁适用于每个运行测试的环境。现在还可以使用这两个方法为每个测试创建和销毁一个全新的应用程序:

microblog/tests.py:为每个测试创建一个应用程序

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

新应用程序将存储在 self.app中,但创建应用程序不足以使一切正常。得考虑到db.create_all()创建数据库表的语句。db实例需要知道应用程序实例是什么,因为它需要从app.config中取得数据库URI,但是当有一个应用程序的工厂,是不是真的局限于单一的应用程序时,可能有一个以上的创建。那么db 如何知道使用我们刚刚创建的 self.app实例?

答案就在应用程序上下文中。还记得 current_app变量,当没有要导入的全局应用程序时,这个变量以某种方式充当应用程序的代理吗?这个变量在当前线程中查找活动应用程序上下文,如果找到,则从中获取应用程序。如果没有上下文,则无法知道哪个应用程序处于活动状态,因此 current_app会引发异常。我们可以在下方看到 它在Python控制台中的工作原理。这需要是通过运行 python启动的控制台,因为flask shell命令会自动激活一个应用程序上下文以方便使用。

(venv) D:\microblog>python
Python 3.6.3 (v3.6.3:2c5fed8, Oct  3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from flask import current_app
>>> current_app.config['SQLALCHEMY_DATABASE_URI']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "D:\microblog\venv\lib\site-packages\werkzeug\local.py", line 347, in __getattr__
    return getattr(self._get_current_object(), name)
  File "D:\microblog\venv\lib\site-packages\werkzeug\local.py", line 306, in _get_current_object
    return self.__local()
  File "D:\microblog\venv\lib\site-packages\flask\globals.py", line 51, in _find_app
    raise RuntimeError(_app_ctx_err_msg)
RuntimeError: Working outside of application context.

This typically means that you attempted to use functionality that needed
to interface with the current application object in some way. To solve
this, set up an application context with app.app_context().  See the
documentation for more information.
>>> from app import create_app
>>> app = create_app()
[2018-08-29 14:42:53,590] INFO in __init__: Microblog startup
>>> app.app_context().push()
>>> current_app.config['SQLALCHEMY_DATABASE_URI']
'sqlite:///D:\\microblog\\app.db'

这就是秘密!调用视图函数之前,Flask推送一个应用程序上下文,这使current_app 和 g处于活动。请求完成后,将删除上下文、以及这些变量。对于在单元测试setUp()方法中的db.create_all()调用,我推送了刚刚创建的应用程序实例的应用程序上下文,并以这种方式,db.create_all()可以使用current_app.config来知道数据库的位置。然后,在tearDown()方法中弹出上下文将所有内容重置为干净状态。

应该还知道 应用程序上下文 是Flask使用的两个上下文之一。还有一个 请求上下文,它更具体,因为它适用于请求。在处理请求之前激活请求上下文时,Flask的requestsession变量、以及Flask-Login的current_user都将变得可用。

环境变量

正如在构建这个应用程序时看到的那样,有许多配置选项依赖于在启动服务器之前在我们的环境中设置变量。包括 密钥、电子邮件服务器信息、数据库URL、百度翻译API密钥等。这样做很不方便,因为每次打开新的终端会话时,都需要再次设置这些变量。

依赖于大量环境变量的应用程序的常见模式是将它们存储在根应用程序目录中的.env文件中。应用程序会启动时就会导入此文件中的变量,这样就不需要手动设置所有这些变量。

有一个支持.env文件的Python包,名为 python-dotenv。

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

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

因此,现在可创建一个包含应用程序所需的所有环境变量的 .env文件。不过请不要将.env文件添加到 源代码管理中,因为肯定不希望文件包含源代码存储库包含的密码和其他敏感信息。

这个.env文件可用于所有的配置时变量,但是它不能用于Flask的 FLASK_APPFLASK_DEBUG公共环境变量(可使用.flaskenv文件,参考文档),因为这些在应用程序的引导过程中是非常早期就需要,即在应用程序实例和其配置对象存在之前。

以下示例显示了microblog/microblog.env文件:

MAIL_SERVER=smtp.163.com
MAIL_PORT=25
MAIL_USE_TLS=True
MAIL_USE_SSL=False
MAIL_USERNAME=xxxx@163.com
MAIL_PASSWORD=授权码
APPID=you-baidufanyi-appid
BD_TRANSLATOR_KEY=you-baidufanyi-secret

依赖文件

到目前为止,已在Python虚拟环境中安装了相当多的软件包。如果需要在另一台机器上重新生成环境,那么将无法记得必须安装的软件包,因此普遍接受的做法是在项目根文件夹中编写一个requirements.txt文件,列出所有的依赖项、以及其版本。生成这个列表实际很简单:

(venv) D:\microblog>pip freeze > requirements.txt

pip freeze命令 将把安装在了正确格式的虚拟环境中的所有软件包存储在requirements.txt文件中。现在,如果需要在另一台计算机上创建相同的虚拟环境,而不用逐个安装软件包,那么可运行:

pip install -r requirements.txt

目前为止,项目结构

microblog/
	app/
		auth/
		errors/
		main/
		static/
		templates/
			auth/
			email/
			_post.html
			base.html
			edit_profile.html
			index.html
			user.html
		__init__.py
		cli.py
		email.py
		models.py
		translate.py
	logs/
	migrations/
	env/
	app.db
	babel.cfg
	config.py
	microblgo.env
	microblog.py
	requirements.txt
	tests.py

参考
https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-xv-a-better-application-structure

posted @ 2020-10-21 14:21  [豆约翰]  阅读(1687)  评论(0编辑  收藏  举报