第十二章 python与flask的结合应用(1/2)



一、编写忘记密码

1.1 成品的网站功能展示

为什么要向上面的人发邮件啊?

确定用户的身份:只有邮箱的主人才能看到这封重置密码的邮件。总不能让一个用户随便填写一个邮箱号,或填成别人的邮箱号,你也进不去这个邮箱啊。

1.2 确定用户身份的三种方式

以前,确定用户的身份是通过cookie;但是,现在是没有cookie的。怎么办?

推荐第二种和第三种,一个是直接将用户id信息发向客户端,另一种是间接发向。

二、 first_or_404

2.1 返回邮箱账号填写页面

在本项目中:

下面会报错,因为视图函数没有返回值。

在视图函数app|web|auth.py中:·

...


@web.route('/reset/password', methods=['GET', 'POST'])
def forget_password_request():
    return render_template('auth/forget_password_request.html')                # 对应的是get方法

在浏览器中再次点击忘记密码按钮,进入到邮箱账号填写页面:

2.2 用户账号不存在的两种处理思路

因为邮箱账号是用户输入的,该邮箱账号可能是非法的,所以要先进行验证操作。

用户账号不存在的处理思路:

思路1:参考上面的登录视图函数的用户验证

if user:
    pass
else:
    flash('邮箱账户不存在')

思路2:借助flask提供的快捷方法:first_or_404()

在视图函数app|web|auth.py中:·

...

@web.route('/reset/password', methods=['GET', 'POST'])
def forget_password_request():
    if request.method == 'POST':
        form = EmailForm(request.form)                          # 实例化一个EmailForm
        
        if form.validate():                                        # 如果email 符合验证规则
            account_email = form.email.data                      # 拿到用户的邮箱账号
            user = User.query.filter_by(email=account_email).first_or_404()
            
    return render_template('auth/forget_password_request.html')

输入非法的邮箱账号,返回的页面结果没有任何错误提示:这是因为实例化的form,没有传入到render_template

改正

在视图函数app|web|auth.py中:·

...

@web.route('/reset/password', methods=['GET', 'POST'])
def forget_password_request():
    form = EmailForm(request.form)                   # 一定要将实例化的对象放到外面!不跟上面一样![警示] 
    if request.method == 'POST':
        if form.validate():                                      
            account_email = form.email.data                     
            user = User.query.filter_by(email=account_email).first_or_404()
    return render_template('auth/forget_password_request.html', form=form)      # 实例化的`form`,没有传入到`render_template`中

输入非法的邮箱账号,返回的页面结果有了报错提示:

2.3 first_or_404()的作用

1.作用演示

输入一个符合邮箱标准、但是数据库中不存在的邮箱账号:

以上就是firs_or_404()的作用。即:抛出异常

2.两个好处

(1)遇到空值,抛出异常、后续代码不执行

如果是下面这种:此次查询没找到结果,user将被赋予一个空值,后面的流程也会继续执行。

      user = User.query.filter_by(email=account_email).first()
      pass
return render_template('auth/forget_password_request.html')

如果是下面这种:一旦查询没找到结果,user被赋予一个空值,那么将会抛出异常、后续的代码将不执行了。

      user = User.query.filter_by(email=account_email).first_or_404()
      pass
return render_template('auth/forget_password_request.html')

(2)判空操作中,避免每次都手动抛异常

因为判空操作经常被使用,所以上面的封装写法是非常有用的

      user = User.query.filter_by(email=account_email).first()
      if not user:
            raise Exception
      pass      
return render_template('auth/forget_password_request.html')

2.4 first_or_404()的原理机制

1.图示关系

2.源码关系

ctrl+b找不到?脚本或动态语言,有时候会有这个问题。去query对象中找。

看下abort()的源码:

三、 callable 可调用对象的意义

3.1 可调用对象:跟函数一样调用的对象

类的实例化对象,怎么也可以像函数一样的用小括号来调用呢?

细看对象的类Aborter()的源码:

只要一个类的内部,定义了上面那个特殊的__call__()方法,那么就可以把个类的实例化对象,当作函数一样来调用。

def abort(
    status: t.Union[int, "Response"], *args: t.Any, **kwargs: t.Any
) -> "te.NoReturn":
   
    return _aborter(status, *args, **kwargs)            # 这里实质上就是调用类Aborter()下面的__call__()方法


_aborter: Aborter = Aborter()

_aborter这种,可以被当作函数来调用的对象,叫做可调用对象。

3.2 可调用对象的两个作用

有什么用,用黑点来调用不就可以了?

一般用于抽象编程。

普通的对象和函数混杂:

class A():                    # 形式:类
    def go(self):
        return object()
    
    
class B():                   # 形式:类
    def run(self):
        return object
    
    
def func():                  # 形式:函数
    return object

def main(param):           # 我们不知道传入的参数的形式是类,还是函数?导致下面的接口不统一
    a.go()                  # 类的调用方式
    b.run()                                   
    func()                  # 函数的调用方式
     
    
main(A())
main(B())
main(func)

可调用对象与函数混杂:

class A():                    
    def __call__(self):            # __call__()方法,让该类变成了可调用对象
        return object()


class B():                       # __call__()方法,让该类变成了可调用对象
    def __call__(self):
        return object


def func():                      # 形式:函数
    return object

def main(callable):             # 既然知道是可调用对象,那就好好办了,一律按函数形式调用,接口统一
    callable()


main(A())
main(B())
main(func)

下面箭头所指的就是可调用对象

四、 HTTP Exception

关注下__call__()方法里边的相关代码:

class Aborter:
    def __init__(
        self,
        mapping: t.Optional[t.Dict[int, t.Type[HTTPException]]] = None,
        extra: t.Optional[t.Dict[int, t.Type[HTTPException]]] = None,
    ) -> None:
        if mapping is None:
            mapping = default_exceptions            # 下面的mapping对象是由default_exceptions装载
        self.mapping = dict(mapping)
        if extra is not None:
            self.mapping.update(extra)

    def __call__(
        self, code: t.Union[int, "Response"], *args: t.Any, **kwargs: t.Any
    ) -> "te.NoReturn":
        from .sansio.response import Response
        if isinstance(code, Response):
            raise HTTPException(response=code)                    # 抛出异常
        if code not in self.mapping:
            raise LookupError(f"no exception for {code!r}")          # 抛出错误
        raise self.mapping[code](*args, **kwargs)                 # 抛出mapping对象里的异常

扫描当前模块下所有的类,如果某一个类是继承自HTTPException的,那么就会把它存入default_exceptions空字典中。

default_exceptions: t.Dict[int, t.Type[HTTPException]] = {}

def _find_exceptions() -> None:                    # 若想要扫描一个文件或一个模块下面的所有的对象,可借鉴此思想
    for obj in globals().values():
        try:
            is_http_exception = issubclass(obj, HTTPException)
        except TypeError:
            is_http_exception = False
        if not is_http_exception or obj.code is None:
            continue
        old_obj = default_exceptions.get(obj.code, None)
        if old_obj is not None and issubclass(obj, old_obj):
            continue
        default_exceptions[obj.code] = obj                    # 将所有的异常对象,存进空字典 default_exceptions中


_find_exceptions()

上面讲的异常,与最终要探究的页面出现404这个现象有什么关系呢?

first_or_404抛出的不是基类HTTP Exception ,而是基类HTTP Exception 的一个子类。

异常的response对象在哪里构建呢 ?

五、 装饰器@ app_errorhandler:AOP的应用

5.1 实现自定义的404页面

如何实现自定义的404页面?

app|templates|404.html中:

<h1>别瞎访问,没这个页面</h1>

在视图函数app|web|auth.py中:

...

@web.route('/reset/password', methods=['GET', 'POST'])
def forget_password_request():
    form = EmailForm(request.form)                    
    if request.method == 'POST':
        if form.validate():                                      
            account_email = form.email.data    
            
            try:
                user = User.query.filter_by(email=account_email).first_or_404()        # 去数据库的User表中查下,该邮箱对应哪个用户
            except Exception as e:
                return render_template('404.html')

    return render_template('auth/forget_password_request.html', form=form)       # 实例化的`form`,没有传入到`render_template`中

输入一个符合邮箱标准、但是数据库中不存在的邮箱账号:

成功自定义了404页面:

5.2 体现AOP思想的装饰器@ app_errorhandler

1.在每个可能出现404的地方,都要try except 吗?太麻烦

不用。利用体现AOP思想的装饰器@ app_errorhandler

在初始化文件app|web|__init__.py中:

from flask import Blueprint, render_template

web = Blueprint('web', __name__)


@web.app_errorhandler(404)          # 监控所有状态码是404的http异常,从而实现返回自定义的404页面
def not_found(e):                   # 只监听404
    return render_template('404.html'), 404


from app.web import book        # 有多少视图函数分布的模块,都要在这里都要导入执行下,从而执行其代码、完成视图函数的注册
from app.web import auth
from app.web import drift
from app.web import gift
from app.web import main
from app.web import wish
# from app.web import user
from app.web import test

上述的装饰器app_errorhandler属于蓝图对象,如果不使用蓝图对象,核心对象app中也有这个装饰器。

2.当然,return不只是可以返回上述渲染模板,还可以用其他的很多形式,比如:

from flask import Blueprint, render_template

web = Blueprint('web', __name__)


@web.app_errorhandler(404)          # 监控所有状态码是404的http异常,从而实现返回自定义的404页面
def not_found(e):                   # 只监听404
    return 'not found', 404


from app.web import book        # 有多少视图函数分布的模块,都要在这里都要导入执行下,从而执行其代码、完成视图函数的注册
from app.web import auth
from app.web import drift
from app.web import gift
from app.web import main
from app.web import wish
# from app.web import user
from app.web import test

浏览器页面:

上述体现的是AOP思想:面向切面编程。

即,不要零散的在每一个可能出现404的地方,去try except 去具体的执行,而是把处理的代码集中到一个地方。

本项目中,就是借助flask写好的装饰器@ app_errorhandler

以后有此类需求,也可以自己编写这种装饰器。

六、 发送电子邮件

目前,如果可以根据用户提交过来的邮箱账号,确实查询到该用户。那么,在系统中我们就可以向该邮箱账号发送一封重置密码的电子邮件。

电子邮件在该项目中,是一种重要的通知手段。所以,很多的视图函数中,都需要发送电子邮件。

基于此,最好将发送电子邮件的业务,单独写到一个模块中。

6.1 安装邮件发送插件flask-mail,完成测试代码

Python的标准库中,其实是已经提供了相关电子邮件的接口的。但是接口偏底层、自己控制的参数多,不方便。

flask提供了一个发送电子邮件的插件flask-mail

注意要在代码程序关闭运行的情况下安装。

既然是插件,那么就要在app的初始化时,注册一下。

app|__init__.py中:

from flask_mail import Mail                    # 导入Mail类


mail = Mail                                  # Mail类的实例化

def create_app():
    '''
    用于创建flask的核心对象app
    '''
    app = Flask(__name__)                       # 新的静态文件的路径,相对与app根目录的
    app.config.from_object('app.secure')
    app.config.from_object('app.setting')
    register_blueprint(app)
    db.init_app(app)
    login_manager.init_app(app)                   
    login_manager.login_view = 'web.login'        
    login_manager.login_message = '请先登录或注册'   

    mail.init_app(app)                             # 注册mail插件

    db.create_all(app=app)
    return app

在自定义文件app|libs|mail.py中:

from app import mail                   # 导入在app的初始化文件中实例化的对象mail
from flask_mail import Message         # 导入该插件的 Message 类


def send_mail():  # 标题      即下面配置的MAIL_USERNAME     正文          收件人
    msg = Message('测试邮件', sender='1121611414@qq.com', body='Test', recipients=['1121611414@qq.com'])
    mail.send(msg)                        # 因为是测试邮件,可以自己给自己发邮件

在配置文件中,定义一系列的配置项:

app|secure.py中:

DEBUG = True        
SQLALCHEMY_DATABASE_URI = 'mysql+cymysql://root:1234abcd@localhost:3306/fisher'                              

SECRET_KEY = 'qwertasdfghjklzxcvbnm123456789zx'

# email 配置                            # 有选择性的深挖,一下配置不建议深挖
MAIL_SERVER = 'smtp.qq.com'             # 采用的是腾讯的qq电子邮件服务器
MAIL_PORT = 465
MAIL_USE_SSL = True
MAIL_USE_TSL = False
MAIL_USERNAME = '1121611414@qq.com'     # 自己的qq邮箱
MAIL_PASSWORD = 'siyekmnzbpnygbeg'      # 自己邮箱的授权码,见下

获得自己邮箱的授权码:

做一个简单的邮件发送测试:

在视图函数app|web|auth.py中:

...

@web.route('/reset/password', methods=['GET', 'POST'])   # 支持两种http请求方式:get:显示一个页面,让用户来填写账号  post:处理和接受用户提交的信息
def forget_password_request():
    form = EmailForm(request.form)                      # 将实例化的对象放到外面
    if request.method == 'POST':
        if form.validate():                             # 参数校验,如果email符合验证规则
            account_email = form.email.data

            user = User.query.filter_by(email=account_email).first_or_404()        # 去数据库的User表中查下,该邮箱对应哪个用户
            from app.libs.email import send_mail        # 导入上面定义好的函数
            send_mail()                                  # 调用下这个函数
    return render_template('auth/forget_password_request.html', form=form)        # 对应的是get请求

qq邮箱中成功收到测试邮件:

6.2 结合具体业务,完成电子邮件发送

在自定义文件app|libs|mail.py中:

from app import mail                   # 导入在app的初始化文件中实例化的对象mail
from flask_mail import Message         # 导入该插件的 Message 类


def send_mail(to, subject, template, **kwargs):    # 新增四个惨呼,增加通用性、灵活性      # template:指定模板的名称     # **kwargs:传入到模板里面的一组参数
    msg = Message('[鱼书]'+' '+subject,
                  sender=current_app.config['MAIL_USERNAME'],
                  recipients=[to])
    msg.html = render_template(template, **kwargs)
    mail.send(msg)

在模板email/reset_password.html中:

在视图函数app|web|auth.py中:

...

@web.route('/reset/password', methods=['GET', 'POST'])  
def forget_password_request():
    form = EmailForm(request.form)                     
    if request.method == 'POST':
        if form.validate():                            
            account_email = form.email.data

            user = User.query.filter_by(email=account_email).first_or_404()        
            from app.libs.email import send_mail                    # 导入上面新定义的函数
            send_mail(form.email.data, '重置你的密码',                # 调用该函数,并传入参数
                      'email/reset_password.html', user=user, token='123123')
    return render_template('auth/forget_password_request.html', form=form)       

qq邮箱中成功收到重置密码的邮件:

点击上述地址,结果如下:

这是因为上述URL对应的正是视图函数forget_password(),该函数未编写、还没有返回值。

注意:

如果是上线的产品,用以上的个人方式发送电子邮件是不合适的。应该使用企业电子邮件,得注册一个域名,按照腾讯的设置规则进行设置即可。

七、 使用 PY JWT生成令牌

7.1 编写视图函数forget_password()的业务逻辑

在视图函数app|web|auth.py中:

...

@web.route('/reset/password/<token>', methods=['GET', 'POST'])
def forget_password(token):
    return render_template('auth/forget_password.html')

再次点击上述URL地址,结果如下:

接下来做一些准备工作,已接受用户的新密码:即验证用户表单提交的两个密码。

app|forms|auth.py中:

class ResetPasswordForm(Form):                      # 新定义一个类,用于校验提交的参数:新密码、确认密码
    password1 = PasswordField(validators=[
        DataRequired(),
        Length(6, 32, message='密码长度至少需要在6到20个字符串之间'),
        EqualTo('password2', message='再次输入的密码不相同')])
    password2 = PasswordField(validators=[DataRequired(), Length(6, 32)])

在视图函数app|web|auth.py中:

@web.route('/reset/password/<token>', methods=['GET', 'POST'])
def forget_password(token):            # 注意,这里不要求必须登录。所以用户id就不能从current_user中拿到

    form = ResetPasswordForm(request.form)                  # 调用上面的验证层类
    if request.method == 'POST' and form.validate():        # 如果是POST请求,且通过了form的校验
        pass                                                # 更新密码
    
    return render_template('auth/forget_password.html')

app|models|user.py中:

class User(Base, UserMixin):  
    ...

@staticmethod
def reset_password(token, new_password):                     # 新定义一个函数,用于更新数据库中用户的密码
    '''通过读取token中的用户id号,来知道该新密码是哪个用户的'''
    pass

原课程中:

app|models|user.py中:

from itsdangerous import TimedJSONWebSignatureSerializer as Serializer


class User(Base, UserMixin):                     
    # __tablename__ ='user1'                       
    id = Column(Integer, primary_key=True)            
    nickname = Column(String(24), nullable=False)      
    phone_number = Column(String(18), unique=True)
    _password = Column('password', String(128), nullable=False)              
    email = Column(String(50), unique=True, nullable=False)
    confirmed = Column(Boolean, default=False)        
    beans = Column(Float, default=0)
    send_counter = Column(Integer, default=0)
    receive_counter = Column(Integer, default=0)
    wx_open_id = Column(String(50))               
    wx_name = Column(String(32))
   

    def generate_token(self, expiration=600):           # 记录用户的ID号      # 必须是经过加密和编码的,不能是明文形式

        s = Serializer(current_app.config['SECRET_KEY'], expiration)          # 实例化一个序列化器
        temp = s.dumps({'id': self.id})                                        # 用户信息的写入
        return temp
    
    
-->
❌ImportError: cannot import name 'TimedJSONWebSignatureSerializer' from 'itsdangerous'

更新:

安装pyjwt

pipenv install pyjwt

app|models|user.py中:

import time
import jwt


def generate_token(self):
    '''生成用于邮箱验证的token:记录用户的ID号 + 必须是经过加密和编码的,不能是明文形式'''

    headers = {
        'alg': 'HS256',
        'typ': 'JWT'
    }
    key = current_app.config['SECRET_KEY']              # 密钥
    exp = int(time.time() + 600)                      # 过期时间:当前时间的600秒后
    payload = {
        'name': 'dd',
        'exp': exp
    }
    token = jwt.encode(headers=headers, key=key, payload=payload, algorithm='HS256')
    return token

在视图函数app|web|auth.py中:

@web.route('/reset/password', methods=['GET', 'POST'])   # 支持两种http请求方式:get:显示一个页面,让用户来填写账号  post:处理和接受用户提交的信息
def forget_password_request():
    form = EmailForm(request.form)                      # 将实例化的对象放到外面
    if request.method == 'POST':
        if form.validate():                             # 参数校验,如果email符合验证规则
            account_email = form.email.data

            user = User.query.filter_by(email=account_email).first_or_404()        # 去数据库的User表中查下,该邮箱对应哪个用户
            from app.libs.email import send_mail
            send_mail(form.email.data, '重置你的密码',
                      'email/reset_password.html', user=user,
                      token=user.generate_token())              # 调用下这个函数
    return render_template('auth/forget_password_request.html', form=form)        # 对应的是get请求

在浏览器中:

点击“提交”后,QQ邮箱收到如下密码重置的邮件:

上面的url地址看似是无意义得到,但是包含了用户的id号等信息。

打开以上链接,结果如下:

7.2 重置密码

目前,新密码、确认密码已经传进来了。

app|models|user.py中:

...

class User(Base, UserMixin):         
    def generate_token(self):
            ...

    @staticmethod
    def reset_password(token, new_password):                     # 用于更新数据库中用户的密码
        '''通过读取token中的上节写入的用户id号,知道所传进来的新密码是哪个用户的'''

        key = current_app.config['SECRET_KEY']  # 密钥
        try:
            data = jwt.decode(token, key, algorithms="HS256")
        except:
            return False        # 如果token过期或非法

        uid = data.get('id')

        with db.auto_commit():
            user = User.query.get(uid)
            user.password = new_password
        return True

在视图函数app|web|auth.py中:

...

@web.route('/reset/password/<token>', methods=['GET', 'POST'])
def forget_password(token):                                 # 注意,这里不要求必须登录

    form = ResetPasswordForm(request.form)
    if request.method == 'POST' and form.validate():        # 如果是POST请求,且通过了form的校验
        success = User.reset_password(token, form.password1.data)            # 更新密码

        if success:
            flash('你的密码已经更新,请使用新密码登录')
            return redirect(url_for('web.login'))
        else:
            flash('密码重置失败')

    return render_template('auth/forget_password.html', form=form)

7.3 单元测试

为了测试第二个视图函数,就要访问第一个视图函数。测试流程冗长,且可能是走很多遍。

当前项目还好,只有两个视图函数。

但是,在一些大型项目中,一个业务逻辑可能会涉及到十几个视图函数的调用流程。如果只想测试该流程中最后的几个视图函数,却不得不把前面所有的额流程都走一遍,这非常烦人。

  • 能够确保所写的一个视图函数的正确性,甚至内部更小粒度的send_mail()的正确性。
  • 能够解决测试流程过于冗长的问题

编写单元测试的测试用例,伪造一些数据,以测试第二步视图函数。

单元测试国内国外
不怎么做必须的环节

7.4 异步编程发送电子邮件

几个细节的问题:

(1)重置密码页面,输入邮箱账号提交后,邮件收到前,等待时间长、加载速度比较慢。

(2)收到邮件后,页面没有任何的提示,要去邮箱查看邮件。

(3)邮件发送成功后,不应该还是停留在原密码重置的页面,而是应该跳转到登录页面。

改正:

在视图函数app|web|auth.py中:

@web.route('/reset/password', methods=['GET', 'POST'])  
def forget_password_request():
    form = EmailForm(request.form)                      
    if request.method == 'POST':
        if form.validate():                          
            account_email = form.email.data
            user = User.query.filter_by(email=account_email).first_or_404()       
            send_mail(form.email.data, '重置你的密码','email/reset_password.html', user=user,
                      token=user.generate_token())              

            flash('一封邮件已发送到邮箱'+account_email+',请及时查收')          # 邮件收到的提示

    return render_template('auth/forget_password_request.html', form=form)        

在上述邮件中点击url地址,进入密码重置页面,在填写完新密码、确认密码之后,成功跳转如下:

上述解决了后两个问题。

刚才加载缓慢,是因为send_mail()函数缓慢,而它缓慢是因为需要连接邮件服务器。连接过程、发送时间,不是由写代码的人决定的。

而页面的停顿,是由于必须等到send_mail()函数执行结束后才能最终return结束掉该视图函数的访问。也就是说,如果send_mail()函数时间非常长,那么页面就会一直处于停顿状态。

发送邮件需要很高的实时性吗?

不需要。可以把send_mail()函数放到另外的线程中,异步的执行整个邮件发送的操作。

对比current_appapp
代理对象,是不真实的实例化的核心对象,是真实的
受到线程id的影响,会变
因为每次访问current_app时,都会去栈中读取栈顶元素
无论何地引用,永远都是app=Flask()

如何在send_mail()函数中,拿到真实的flask的核心对象,而不是current_app代理对象?

通过调用_get_current_object()来拿到。

在自定义文件app|libs|email.py中:

from threading import Thread
from flask import current_app, render_template
from app import mail                   # 导入在app的初始化文件中实例化的对象mail
from flask_mail import Message         


def send_async_email(app, msg):                      # 定义一个新的线程
    '''异步发送电子邮件'''
    with app.app_context():             # 应该手动的将app_context上下文推入栈中
        try:
            mail.send(msg)
        except Exception as e:
            pass                     # 这里的异常处理,既可以记日志,也可以直接抛出来


def send_mail(to, subject, template, **kwargs):   
    msg = Message('[鱼书]'+' '+subject,
                  sender=current_app.config['MAIL_USERNAME'],
                  recipients=[to])
    msg.html = render_template(template, **kwargs)

    app = current_app._get_current_object()                        # 拿到真实的flask的核心对象

    thr = Thread(target=send_async_email, args=[app, msg])        # 调用上面的新的线程,中的函数
    thr.start()

进入重置密码页面,输入邮箱账号提交后,加载速度变快、很快就收到了邮件。

由上可知:

异步编程不简单。

如果对性能要求不高,建议同步编程;

如果确实对性能要求高,首先考虑各种优化,实在考虑到极限了,再考虑异步编程。

所以,不要盲目追求并发、异步,绝大多数的人都用到。

声明:Jerry's Blog|版权所有,违者必究|如未注明,均为原创|本网站采用BY-NC-SA协议进行授权

转载:转载请注明原文链接 - 第十二章 python与flask的结合应用(1/2)


Stop chasing money, and start chasing the solutions to the problem.