🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
[TOC] 我们再来分析一下把`rencent`函数放到`Gift`模型下面的合理性? `Gift`模型按照我们的常规的思维可以把它理解成数据库里面的一条记录。那么既然`Gift`代表的是一条记录,一条记录里面有这样的`rencent`取最近的很多条记录会感觉非常的不合理,如果`rencent`是一个实例方法的话,放在`Gift`下面确实是不合适的,因为一个被实例化的`Gift`对象代表的是一个礼物,一个礼物里面有取多个礼物的方法确实是不合适的,但是如果说我们把`rencent`实例方法变成类方法的话,放在我们`Gift`类下面,那么就是合适的。 ~~~  def recent(self):      recent_gift = Gift.query.filter_by(launched=False).group_by(          Gift.isbn).order_by(          Gift.create_time).limit(        current_app.config['RECENT_BOOK_COUNT']).distinct().all()      return recent_gift ~~~ 将**实例方法**改写成**类方法**: ~~~  @classmethod  def recent(cls):      recent_gift = Gift.query.filter_by(launched=False).group_by(          Gift.isbn).order_by(          Gift.create_time).limit(          current_app.config['RECENT_BOOK_COUNT']).distinct().all()      return recent_gift ~~~ 因为对象具体到一个礼物,但是类确实代表自然界里的一个事物,它是抽象的,并不是具体的**一个**,而我们实例方法是和对象对应的,类方法是和类对应的。最近的礼物确实可以属于礼物这个`事物`的,但是呢它不应该属于具体的某一个礼物,这个就是对`类`和`对象`的一个深入的思考,这种写法也是对类和对象的具体的应用。 之前我们说了,`rencet`除了写在`Gift`之外还可以写在其他地方,`rencet`函数的实质其实就是做了一次`SQL`的查询,既然这样那我们也可以把这次查询写到视图函数里面,但是这种方法不推荐,最推荐的还是把`rencet`集中到`Gift`模型下面来。 * **总结**:有具体业务意义的代码不要放在视图函数里 * 如果你的代码能够提取出具体的业务意义,建议放到模型下面 * 如果你的代码不能够提取出具体的业务意义,确实可以放到视图函数里面另一种`rencet`的写法: 我们可以把获取最近上传的书籍作为一个新的模型为它单独新建一个模型文件例如:`RencentGift`。 ### 编写我的赠送清单页面(`my_gifts`页面) ![](https://ws4.sinaimg.cn/large/0069RVTdgy1fv9fcluud6j31kw0i0nok.jpg) ![](https://ws4.sinaimg.cn/large/0069RVTdgy1fv9fbfwszcj31kw0baaxq.jpg) 在理清赠送清单页面逻辑的时候,根据多年的经验,可以想到有两种编写方式(如上图)。 * 第一种,我们肯定是需要查询出**我的所有礼物**,然后再遍历**我的所有礼物**,根据书籍的`isbn`编号去查询这本书籍的心愿数量,假定一本书籍的心愿数为`N`,那么这种方法需要查询数据库的次数:`N+1`,因为`N`不可控 * 优点:思路简单 * 缺点:循环遍历数据库是不可以接受的 * 第二种,先查询出**我的所有礼物**,将所有书籍的`isbn`编号取出来组成一个列表,然后使用`mysql`的`in`查询去`Wish`表中查询所有属于`isbn`列表中的心愿,并计算其数量 * 优点:`2`次数据库的查询 * 缺点:代码复杂 ### `db.session`查询 `db.session.query(Wish)`需要传入查询的主体,这里我们需要在`Wish`表中查询所以使用`Wish` #### `filter`与`filter_by`的区别: * `filter_by`内部的实质是调用`filter`。`filter_by`传入的是关键字参数。 ~~~  Gift.query.filter_by(launched=False, uid=uid) ~~~ * `filter`比`filter_by`更加灵活、强大。`filter`接收的是条件表达式。 ~~~  db.session.query(Wish).filter(Wish.launched == False) ~~~ ### 如何通过`sqlalchemy`来做`mysql`的`in`查询 ~~~  使用示例:Wish.isbn.in_(isbn_list)  ​  db.session.query(Wish).filter(Wish.launched == False,                                Wish.isbn.in_(isbn_list),                                Wish.status == 1).all() ~~~ #### 函数返回的数据结构 非常不建议直接在函数中返回元组或者列表的数据结构, * 建议返回**对象**,因为对象是有属性的,每个属性是有具体意义的。 * 最经典的数据结构:字典。 #### `python`中有快速定义对象的方法:`namedtuple` ~~~  from collections import namedtuple  ​  # 定义对象  EachGiftWishCount = namedtuple('EachGiftWithCount', ['count', 'isbn'])  ​  # 使用方法,这里的count_list是一个元组的列表,列表里每个元素都是一个元组  count_list = [EachGiftWithCount(w[0], w[1]) for w in count_list] ~~~ `namedtuple`接收第一个字符串,这个字符串就是你定义的对象的名字,后面是一个列表,这个列表代表了你定义的对象下面的相关属性,最后将其赋给`EachGiftWithCount`这个变量。 #### `db.session.query`查询方式和`Gift.query.filter_by`模型查询方式的区别 * 如果单纯的就是查询模型的话,显然使用`Gift.query.filter_by`模型查询最为快速的 * 如果查询比较复杂,或者说涉及到跨表查询、跨模型查询的时候,`db.session.query`这种查询方式是更好的。 #### 多层嵌套的`for in`循环 代码大全里介绍到我们可以将第二个`for in`循环提出来作为一个单独的函数来写,然后在第一个`for in`循环内调用该函数 > 小技巧: > > 不建议在方法里面修改实例变量,原因很简单,如果你直接在类的某一个方法里直接修改实例属性的话,那么就会存在问题。当你的类很复杂,方法很多的时候,你根本不知道实例属性会在哪个方法中被修改,但是读取实例属性是没有关系的,因为读取不会修改实例属性的值。 > > * 直接在类的方法里修改类的实例属性 > > > ~~~ >  class MyGifts(): >     def __init__(self, gifts_of_mine, wish_count_list): >         self.gifts = [] >  ​ >         self.__gifts_of_mine = gifts_of_mine >         self.__wish_count_list = wish_count_list >  ​ >         self.__parse() # 调用__parse方法,否则没用 >  ​ >     def __parse(self): >         for gift in self.__gifts_of_mine: >             my_gift = self.__matching(gift) >             self.gifts.append(my_gift) >         return self.gifts > ~~~ > > * 不在类的方法里修改类的属性,而是应该在方法里将结果返回,在构造函数内修改实例属性的值 > > > ~~~ >  class MyGifts(): >     def __init__(self, gifts_of_mine, wish_count_list): >         self.gifts = [] >  ​ >         self.__gifts_of_mine = gifts_of_mine >         self.__wish_count_list = wish_count_list >  ​ >         self.gifts = self.__parse() # 调用__parse方法,否则没用 >  ​ >     def __parse(self): >         tem_gifts = [] >         for gift in self.__gifts_of_mine: >             my_gift = self.__matching(gift) >             tem_gifts.append(my_gift) >         return tem_gifts > ~~~ > > 请看以上两种方式的区别。 #### 用户注销 使用`logout_user`函数 ~~~  from flask_login import login_user, logout_user  ​  @web.route('/logout')  def logout():      logout_user()      return redirect(url_for('web.index')) ~~~ `logout_user` 其实就是将浏览器的 `cookie` 清空 ### 再遇循环引用 `python`里的循环导入为什么头疼主要是因为`python`不会直接在总结性的信息里告诉你**这是一个由于循环导入所引起的问题**。所以这就要求开发者有丰富的经验,要对常见的由于循环导入所引起的问题有一个很敏感的意识。 > 七月心语: > > 循环导入是`python`中常见的问题,和设计没有关系,出现循环导入是一种很正常的现象,我从来都不认为,出现循环导入是你的设计做得不够好,网上很多教程都说出现循环导入就是你的设计不够好,我从来不这么认为。因为我们从面向对象的角度来看,出现循环导入是很正常的,举个例子:你和你的对象,你们之间痛苦的时候会互相倾述,开心的时候会相互分享,这是正常相互作用的关系。那放在我们代码里面来说,两个对象之间也会有相互作用的关系,那么它就会存在这样的循环导入。 循环导入的原因: 第一次从**模块一**导入的时候,先进入模块一,在走到**需要导入的类**之前又遇到其他导入语句,所以立马跳到**模块二**,所以之前**需要导入的类**并没有导入,等到第二次需要导入该类的时候,因为`python`以为第一次已经导入过了,所以就不会执行导入语句,会直接从缓存中查找,但是由于之前并没有实际导入,所以找不到报错`can't import` 解决方法: 1. 将导致循环导入的语句放到不能导入的类的下方 等先执行这个类的导入之后在执行导入语句,那么后面再次导入该类的时候就能找到该类了 2. 在需要调用的时候导入一下,在哪里用就在那里导入 两种方法都可以,七月老师更喜欢第二种方法,那就第二种吧。 > 小技巧: > > 当你遇到一个错误的时候,第一件事情就是把整个错误信息拖到最下面看最后的总结性的错误提示,绝大多数情况下,根据总结性的错误提示,我们其实就可以找到错误的原因,如果根据错误提示你找不到原因的话,那么就需要从下往上看具体的**错误堆栈信息**。 > > ![](https://ws1.sinaimg.cn/large/0069RVTdgy1fv9d5g4tohj31kw0n6e82.jpg) ### 重复代码的封装技巧 我们来谈一谈`MyTrades`的意义: 表面上来看`MyTrades`只是将原来的`MyGifts`、`MyWishes`合并成了一段代码,但是严格意义上来说`MyTrades`实际上是`MyGifts`和`MyWishes`的基类,鱼书项目中之所以没有体现出`MyTrades`作为基类的特性(被继承)在于`MyWishes`和`MyWishes`的业务逻辑是一模一样的,所以我们是可以使用基类来代替这两个子类的。 需要强调的是,如果`MyGifts`和`MyWishes`出现了行为逻辑上的差异的话,那么我们就需要单独定义`MyGifts`和`MyWishes`去继承`MyTrades`,然后在子类里面去实现自己特定的业务逻辑。 > 小技巧: > > 关于模型命名的问题,模型的命名需要经过慎重的思考,英语的重要性在这里就体现出来了,一定要找到一个比较合适而又简洁的命名。 > > 这样才会让你的代码编写更加轻松! > > ![](https://ws4.sinaimg.cn/large/0069RVTdgy1fv9dj1zy4gj31kw0qxb2a.jpg) ### 忘记密码 #### `first_or_404()` ~~~  @web.route('/reset/password', methods=['GET', 'POST'])  def forget_password_request():      form = EmailForm(request.form)  ​      if request.method == 'POST' and form.validate():          count_email = form.email.data          user = User.query.filter_by(email=count_email).first_or_404()          pass  ​      return render_template('auth/forget_password_request.html') ~~~ 上面代码使用的是`first_or_404()`: * 在`filter_by()`后面使用的是`first()`,如果这次查询没有找到任何结果,那么这个`user`将会被赋予**空值**,后面的流程会继续往下面执行。 * 在`filter_by`后面使用的是`first_or_404()`,如果查询不到任何结果的话,后续代码将不会被执行。因为在`first_or_404()`方法的内部会抛出一个异常,终止代码运行。 ![](https://ws1.sinaimg.cn/large/006tNbRwgy1fvae9sm0trj31hy0aajs4.jpg) 最终返回的是`Notfound`对象,`Notfound`下面的`description`与最终页面返回的描述一模一样,为什么呢? 页面输出什么完全取决于`response`响应对象。根据这样的理论,我们可以推测,最终构建的`response`响应对象一定读取了`Notfound`下面的`description`。 当我们调用`first_or_404()`之后会抛出一个异常,异常之后的代码都不会被执行,也就是最终构建的`response`对象一定不是从视图函数的`return`语句里构建的,那么这个`response`在哪构建的呢? 在基类`HTTPException`里面定义了一个方法`get_response`,异常`response`就是在这里构建的。 > 七月心语: > > 大家需要充分了解异常对象,特别是对象的基类`HTTPException`,原因在于,如果你需要用`flask`去做一个比较好的`restful api`,那么就需要重写`HTTPException`。我们下一门课程讲**restful标准API**的时候,大家就会发现重写`HTTPException`将会是最优雅、最好的`restful`异常的实现。 ~~~  class NotFound(HTTPException):  ​      """*404* `Not Found`  ​     Raise if a resource does not exist and never existed.     """      code = 404      description = (          'The requested URL was not found on the server. '          'If you entered the URL manually please check your spelling and '          'try again.'     ) ~~~ > 小技巧: > > 如果一段`python`代码中间的某一处抛出了异常之后,这个异常之后的代码是不会被继续执行的。 > 小技巧: > > 如果以后需要扫描一个文件或者说一个模块下面所有对象的话,可以借鉴`_find_exceptions`的实现方法。 > > ~~~ >  default_exceptions = {} >  __all__ = ['HTTPException'] >  ​ >  ​ >  def _find_exceptions(): >   for name, obj in iteritems(globals()): >       try: >           is_http_exception = issubclass(obj, HTTPException) >       except TypeError: >           is_http_exception = False >       if not is_http_exception or obj.code is None: >           continue >       __all__.append(obj.__name__) >       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 >  _find_exceptions() >  del _find_exceptions > ~~~ #### `@app_errorhandler()`装饰器 上面调用`first_or_404()`之后会出现`flask`默认的`404`页面,这样并不太友好,如果我们想返回我们自定义的页面呢? * 方案一 我们可以再`first_or_404()`前后加上`try exception`,当触发异常的时候使用`exception`返回自定义的页面(代码如下),确实可以得到我们想要的结果。 ~~~  try:      user = User.query.filter_by(email=count_email).first_or_404()  except Exception as e:   return render_template('404.html') ~~~ > 对于整个项目而言,可能有很多个地方都会出现这样的`404`异常,那么像这样每个地方都要写这样的一段代码是不是很繁琐?有没有好的解决方案呢? * #### 方案二(学习重点) 我们只需要在蓝图的初始文件里构造这样一个带`@app_errorhandler`装饰器的`not_found`函数就可以了,这样就可以实现监控所有状态码为`404`的`http`异常,从而实现返回自定义的页面,当然也不一定必须返回`404`,因为在`not_found`里是可以实现任意代码需求的,可以在这里进行更加细致的处理。比如:记录具体的异常信息到日志里,那么就从参数`e`里读取异常信息并且写入到日志。 ~~~  from flask import render_template  ​  @web.app_errorhandler(404)  def not_found(e):      return render_template('404.html'), 404 ~~~ * 如果没有使用蓝图的话,在`flask`核心对象`app`里也同样有`@app_errorhandler`装饰器,这就是七月老师常说的,蓝图的很多方法和`flask`核心对象是一模一样的。 `@app_errorhandler()`装饰器接收一个状态码参数,接收什么就监控什么。 #### `AOP`思想的应用 `AOP`思想其实就是**面向切片编程**。 大体意思就是我们不要在每一个可能会出现`404`的地方去处理异常,而是把所有的处理的代码集中到一个地方处理。上面`@app_errorhandler()`装饰器就是一种基于`AOP`思想的应用。这个`AOP`思想的实现是借助于`flask`已经帮我们写好的`@app_errorhandler()`装饰器。 > 七月心语: > > 建议大家以后在写框架类型的代码的时候,如果你追求这样一种非常好的编码方式的话,那么你也要能考虑到我可不可以自己来写一个装饰器,来实现这种统一的、集中式的处理。 #### 可调用对象的意义 一般来说,函数或者方法可以通过传递参数进行调用,但是对象不可以。 如果你想把一个对象当做函数来调用,那么这个对象内部必须实现一个特殊的方法,`__call__`方法。一旦我们在一个类的内部实现了这个特殊的`__call__`方法之后,我们就可以把这个对象当做函数来调用。 > 下面代码实质上就是调用了`Aborter`类的`__call__`方法。 > > ~~~ >  def abort(status, *args, **kwargs): >   return _aborter(status, *args, **kwargs) >  ​ >  _aborter = Aborter() >  ​ >  --------------------------------------------------------------------------------------- >  class Aborter(object): >   def __init__(self, mapping=None, extra=None): >       if mapping is None: >           mapping = default_exceptions >       self.mapping = dict(mapping) >       if extra is not None: >           self.mapping.update(extra) >  ​ >   def __call__(self, code, *args, **kwargs): >       if not args and not kwargs and not isinstance(code, integer_types): >           raise HTTPException(response=code) >       if code not in self.mapping: >           raise LookupError('no exception for %r' % code) >       raise self.mapping[code](*args, **kwargs) > ~~~ 对于这种可以被当做函数直接来调用的对象,我们称作**可调用对象**。可调用对象的实质就是在它的内部实现`__call__`方法。 可调用对象在普通编程的时候是用不到的,只有在做一些比较抽象的编程的时候才会发现它的用处。 ![](https://ws1.sinaimg.cn/large/006tNbRwgy1fvad0s85f1j31kw0hnq8h.jpg) > 可调用对象作为统一调用的接口的实例: > > ~~~ >  # 类 >  class A(): >   def go(self): >       return object() >  # 实例化A为对象 >  a = A() >   >  # 类 >  class B(): >   def run(self): >       return object() >  # 实例化B为对象 >  b = B() >  ​ >  # 函数 >  def func(): >   return object() >  ​ >  def main(callable): >   callable() >   pass >  ​ >  main(A()) >  main(B()) >  main(func) > ~~~ > > 我想在main中调用传入的参数,得到一个object对象, > > 如果传入的是对象`a`的话,`a.go()`; > > 如果传入的是对象`b`的话,`b.run()`; > > 如果传入的是函数`func`的话,`func()`; > > 关键问题在于`main`并不知道传入的参数是什么类型、具体是什么!如果`main`接收的是函数类型的话,那就很简单了,直接使用`param()`的形式调用就可以了,那么对象`a`、`b`可不可以使用函数的调用形式呢?显然是可以的,将`a`、`b`转化为可调用对象。就可以在`main`中统一调用形式,不需要过问参数的具体类型。 #### 发送电子邮件 ##### `email`配置 ~~~  MAIL_SERVER = 'smtp.qq.com'  MAIL_PORT = 465  MAIL_USE_SSL = True  MAIL_USE_TSL = False  MAIL_USERNAME = 'schip@qq.com'  MAIL_PASSWORD = 'qfhglcwpkehyebeh'  # MAIL_SUBJECT_PREFIX = '[鱼书]'  # MAIL_SENDER = '鱼书<hello@yushu.im>' ~~~ 参数解释: * `MAIL_SERVER`:指定电子邮箱服务器的地址,`QQ`的服务器地址是`smtp.qq.com` 网站向用户发送电子邮件,理论上来说是需要一个电子邮件服务器的,但是没必要自己去搭建,可以使用公开的电子邮件服务器,比如想腾讯提供的`QQ`电子邮箱服务器、也可以使用`163`的,还有很多其他的。 * `MAIL_PORT`:指定电子邮箱服务器的端口,`QQ`的端口是`564` * `MAIL_USE_SSL`:`SSL`协议,`QQ`使用的是该协议,`True` * `MAIL_USE_TSL`:`TSL`协议,`QQ`使用的是`SSL`自然不可能再用`TSL`,`False` * `MAIL_USERNAME`:发件人邮箱地址 * `MAIL_PASSWORD`:密码,对应`QQ`电子邮箱服务器来说会生成授权码,就是这个授权码 ![](https://ws1.sinaimg.cn/large/006tNbRwgy1fvb5oypbr1j31gc0dwn26.jpg) > 七月心语: > > 学习编程要有选择性的深挖,比如像这些`email`配置,不建议深挖,因为你并不是专业从事电子邮件研发的工程师,其实你只需要掌握这些参数然后运用这些参数发送电子邮件就可以了,时间是一种非常稀缺的资源,你把时间花在研究参数上面,还不如把时间花在更有价值的地方,比如深挖`python`装饰器高级用法,相对于专研参数来说这个对你更有帮助。 ##### 任意测试邮件 ~~~ from flask_mail import Message from app import mail def send_email(): msg = Message('测试邮件', sender='schip@qq.com', body='Test', recipients=['schip@qq.com']) mail.send(msg) ~~~ `Message()`参数: * 第一个参数是邮件的标题 * `sender`:发件人 * `body`:邮件正文内容 * `recipients`:收件人 调用`mail.send()`就可以进行发送测试。 ##### 注册用户测试邮件 注册用户测试邮件就是必须要验证该邮件是否已经注册,不注册会报错,其实很简单,只需要在上述任意测试邮件里传入相应的参数就可以了,因为我们会将提交的邮件去数据库里查询使用该邮件注册的用户,如果找不到则说明该邮件没注册,会报错提示(后面继续优化)。 ~~~ from flask import current_app, render_template from flask_mail import Message from app import mail def send_email(to, subject, template, **kwargs): msg = Message('鱼书'+''+ subject, sender=current_app.config['MAIL_USERNAME'], recipients=[to]) msg.html = render_template(template, **kwargs) mail.send(msg) --------------------------------------------------------------------------------------- @web.route('/reset/password', methods=['GET', 'POST']) def forget_password_request(): form = EmailForm(request.form) if request.method == 'POST' and form.validate(): count_email = form.email.data user = User.query.filter_by(email=count_email).first_or_404() from app.libs.email import send_email send_email(form.email.data, '重置你的密码', 'email/reset_password.html', user=user, token='123123') return render_template('auth/forget_password_request.html') ~~~ `send_mail`参数解释: * `to`:收件人 * `subject`:邮件主题(我们在前面加上一些修饰,将其拼接成一个字符串) * `template`:邮件模板(邮件的内容) 任意邮件测试的时候,我们使用的是`body`,`body`一般是用来传入一些文本的,我们知道邮件是可以被格式化的显示的,最好的格式化的方式就是在邮件里显示一段`html`代码,因为`html`是很容易格式化显示出来的。 * `msg.html`:如果我们想在邮件里面加入一段`html`作为邮件的正文的话,我们可以使用`html`参数。我们把一段`html`代码赋给`msg.html`就可以了。 **注意**:此处不能直接把`'auth/email/reset_password.html'`的路径直接赋值给`msg.html`,因为这个模板还需要数据(变量)进行渲染,我们应该把已经渲染好的页面赋值过去。 * `**kwargs`:模板渲染的参数 每个模板要被数据填充渲染,肯定是要先传入这些数据。邮件内需要显示用户名、还有重置密码的链接,这些数据就是从该参数传入的。 > 注意: > > 目前使用的发件人的邮箱是我们个人的邮箱,这个可以用于我们个人开发测试,但是如果你是一个已经上线的产品的话,用个人的邮箱地址向用户发送邮件是不合适的,需要**企业电子邮件**。**企业电子邮件**必须要有一个自己注册的域名,然后按照腾讯企业邮件的设置规则去进行相应的设置就可以了。 ##### 用户新密码提交验证 在用户新密码的设置页面我们需要为用户设置的新密码进行验证,如果不符合密码规范要求展示提示信息。 该页面要求定义两个校验变量`password1`、`password2`。 ~~~ class ResetPasswordForm(Form): password1 = PasswordField(validators=[ DataRequired(), Length(6, 32, message='密码长度至少需要在6到32个字符之间'), EqualTo('password2', message='两次输入的密码不相同')]) password2 = PasswordField(validators=[ DataRequired(), Length(6, 32)]) ~~~ 新密码规范: * 密码长度需要在6到32个字符之间 * 两次输入的密码必须相同 * 进行`DataRrequired()`验证 ##### `token` 当用户提交新密码过来之后,我们接下来要做的就很简单了,更新当前用户密码就行了。那么问题来了,当前用户是谁?用户新密码设置页面的链接是用户邮箱收到的链接,该链接并不要求用户登录,所以我们是拿不到`current_user`的。但是这个链接是包含`token`的链接,是我们在`forget_password_request`视图函数里生成的链接,我们之前就是将用户`id`加密之后放入`token`中,然后再用邮件发送给用户的。 因为加密用户`id`是用户自己的行为,所以我们在`user`模型中我们定义一个方法`generate_token`方法来加密。 `token`必须具备两个最基本的功能: * 记录用户的`id`号 也就是说我们可以在`token`中加入一些我们想加入的信息 * 必须是经过加密和编码 不能以明文的形式将用户的信息存放在`token`中 另外一般情况下我们需要对`token`加上有效时间,所以我们在`generate_token`方法中传入时间参数`expiration`,`expiration`的单位是秒。 `flask`提供了一个非常好用的库叫做`itsdangerous`,我们可以从`itsdangerous`导入一些对象来完成以上两个功能。 ~~~ from itsdangerous import TimedJSONWebSignatureSerializer as Serializer ~~~ 类名太长了,所以使用`as`别名,为了方便使用。 ~~~ from itsdangerous import TimedJSONWebSignatureSerializer as Serializer class User(UserMixin, Base): ... def generate_token(self, expiration=600): s = Serializer(current_app.config['SECRET_KEY'], expiration) tem = s.dumps({'id': self.id}) return tem ~~~ 导入`Serializer`类之后,我们首先要将其实例化,接下来要考虑的是传入什么参数。`Serializer`可以理解成为一个序列化器。 * 第一个参数要求我们传入一个**几乎是独一无二的随机字符串**,刚好我们之前已经在`secure`配置文件中定义过了`SECRET_KEY`也是一个**独一无二的随机字符串**,我们直接将`SECRET_KEY`当做第一个参数传入进来就可以了。 * 第二个参数传入一个有效时间,所以将`expiration`传入进来就可以了。 实例化一个序列化器之后,我们就需要把用户的信息写入到序列化器中,写入的方法是调用`dumps()`方法,这个`dumps()`接收一个字典,我们将字典的键设置为`id`,字典的值设置为当前用户的`id`号,因为我们是在实例方法中编写代码的,所以我们直接使用`self.id`就可以拿到`id`号了。 我们在使用断点调试之后可以看到这个`tem`是在字符串前面带了一个小写的`b`,说明这个`tem`是一个`byte`类型的(是字节码,不是字符串),那么我们要想办法将`byte`类型的转化为字符串。 * 怎么将`byte`转化为字符串类型呢? 很简单,调用`decode()`并且制定字符串的格式为`utf-8`,这样我们就可以得到一个普通的字符串。 ![](https://ws4.sinaimg.cn/large/006tNbRwgy1fvbgsog0hcj31kw09oq7a.jpg) 使用`decode()`之后返回的字符串就是我们最终的`token`。最终代码如下: ~~~ from itsdangerous import TimedJSONWebSignatureSerializer as Serializer class User(UserMixin, Base): ... def generate_token(self, expiration=600): s = Serializer(current_app.config['SECRET_KEY'], expiration) return s.dumps({'id': self.id}).decode('utf-8') ~~~ > 七月心语: > > 我们可以在`token`里面传入任何我们想传入的信息,并不只是用户`id`,要学会举一反三。 ##### 重置密码 用户的新密码在前面已经可以传递进来了,新密码传进来之后我们需要为用户更新密码。我们可以在`user`模型下新建一个`reset_password`方法来更新用户密码: * 显然该方法需要接收新密码作为参数。 * 显然该方法还需要知道这个新密码是属于哪个用户的,所以需要接收`token`作为参数,通过读取`token`里的信息是可以拿到用户`id`的。 `reset_password`里的第一件事情就是去读取用户`id`号,同样要实例化一个`Serializer`序列化器,同样传入`SECRET_KEY`。读取`token`里的信息需要使用`loads()`方法,需要传入`token`作为参数,因为之前使用`decode()`将序列化器返回的内容转化为字符串了,现在进行解码的之前我们同样要先将字符串转化为`byte`类型(字节码),这里使用`encoed('utf-8')`。 ~~~ class User(UserMixin, Base): ... @staticmethod def reset_password(token, new_password): s = Serializer(current_app.config['SECRET_KEY']) try: data = s.loads(token.encode('utf-8')) except: return False uid = data.get('id') with db.auto_commit(): user = User.query.get(uid) user.password = new_password return True ~~~ 序列化器解码之后返回结果我们赋值给`data`,因为我们之前将用户`id`以字典的形式传入进去的,所以我们解码出来`data`就是一个字典,在字典中获取值使用`data.get('id’)`,`id`为字典的键。 > 这里我们需要讨论`token`的安全性: > > 如果有其他人拿到了`token`,他有可能读取用户的信息吗? > > 基本上是不太可能的,因为我们在写入用户信息的时候时使用了**独一无二的字符串**(`SECRET_KEY`)的,别人拿到`token`,没有 `SECRET_KEY`是无法解密的。 下面有两种情况是我们需要考虑的: * `token`确实是由我们的网站生成的,但是它过期了 * `token`不是我们网站生成的,而是别人伪造的 出现以上两种情况说明`token`是非法的,当这种情况出现的时候,在调用`loads()`方法解密的时候会报出异常。既然会报出异常,那么我们可以使用`try except`来检测下, * 如果抛出异常我们返回`False`表明用户重置密码失败; * 如果不抛出异常的话我们接着业务流程更新用户密码,最后返回`True`,表明用户重置密码成功。 这里我们使用`User.query.get(uid)`来查询用户,因为我们使用的是`user`模型的主键用户``id`来查询的,那么我们可以直接使用更加快速的`get()`方法来查,当然你要使用`filter_by()`也没问题。 > `get()`是一种简化的查询形式,当你的查询条件是**模型主键**的时候可以`get()`查询。 > 七月心语: > > 对于`User`的查询,最好加上判空操作,这样更为严谨。 前面阶段已经完成了用户重置密码方法的编写,接下来需要在视图函数内调用该方法,代码如下: ~~~ @web.route('/reset/password/<token>', methods=['GET', 'POST']) def forget_password(token): form = ResetPasswordForm(request.form) if request.method == 'POST' and form.validate(): 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) ~~~ ##### 单元测试 重置密码页面的`forget_password`视图函数已经写完了,那么就需要测试一下。我们来回顾一下整个流程,`forget_password`视图函数是使用`token`的链接调用的,`token`的链接是由忘记密码页面的`forget_password_request`视图函数生成的,如果需要测试`forget_password`视图函数的话得先走一遍`forget_password_request`视图函数的流程才能进行测试。 ![](https://ws3.sinaimg.cn/large/006tNbRwgy1fvbk02ubcdj31kw0dydj1.jpg) 这个流程这么长,走一遍勉强还可以接受,但是实际写代码的时候可能经常需要调试,换句话说这个流程我们可能要走很多遍,这是一个相当烦人的事情。更何况我们现在面对的这个业务逻辑是我们非常熟悉的重置用户密码的业务逻辑,这个业务逻辑比较简单只有两个视图函数,但是在大型的项目中一个业务逻辑可能涉及到十几个视图函数的调用流程,那么假如我们为了测试最后几个视图函数,却不得不把前面每个视图函数都走一遍,那这种调试的情况就非常的烦人。 那么有什么方法可以解决这个问题呢? **单元测试**可以帮我们解决这个问题。 **单元测试**的优点: * 可以确保视图函数的正确性,甚至更小粒度函数的正确性 * 可以解决测试流程过于冗长的问题 通过编写单元测试的测试用例是可以伪造一些数据,直接测试第二个视图函数`forget_password`。 > 单元测试的国内外背景: > > * 国内:很多敏捷开发或者传统开发基本上都是不做单元测试的 > > * 国外:很多的软件开发中,单元测试是必备的环节和流程 > ##### 异步发送电子邮件 点击忘记密码进入输入邮箱页面: * 细节1:点击提交邮箱之后,没有任何提示 ~~~ flash('密码重置邮件已经发送到您的邮箱' + account_email + ',请及时查收!') ~~~ 闪现一条提示消息,搞定! * 细节2:点击提交邮箱之后,没有跳转到其他页面 ~~~ return redirect(url_for('web.login')) ~~~ 重定向到登录页面,搞定! * 细节3:点击提交邮箱之后,发送电子邮件缓慢,页面一直处于加载状态 很明显,加载缓慢的原因是由于`send_mail`引起的,因为发送电子邮件需要连接电子邮件服务器,连接的过程以及发送的时间不是自己能够控制的,有一定的缓慢是非常正常的,因为使用的是第三方的服务器(腾讯的电子邮箱服务器)。 需要考虑的是: 我们提交邮箱之后需要把邮件**实时**的发送到用户邮箱吗?实际上并不需要那么高的实时性,只需要在几分钟内发送到用户邮箱就可以了。我们页面之所以出现停顿的原因就在于等`send_mail`整个函数走完之后才能最终`return`结束掉这次视图函数的访问。如果说`send_mail`时间非常长的话,那么你的页面就会一直处于停顿的状态。 既然`send_mail`不需要这么高的实时性,那么可以将`send_mail`放到另外的线程中**异步发送电子邮件**。 我们先定义一个异步函数`send_async_email`,然后在`send_mail`函数的主线程中启动一个新的线程`Thread`,它的目标就是新定义的异步函数`send_async_email`,使用`args=[]`将异步函数需要的参数传入进去。 ![](https://ws4.sinaimg.cn/large/006tNc79gy1fvbp783xgyj31kw0h17a8.jpg) 异常:`Working outside of application context` 应该很熟悉,解决方案是显而易见的,我们需要手动的把上下文管理器`app_context`推入栈中就行了。 ~~~ def send_async_email(app, msg): with app.app_context(): try: mail.send(msg) except Exception as e: pass pass ~~~ 以上代码可以解决`app_context`的问题。 接下来的问题是`app`从哪里来? 我们在`send_mail`是可以访问到`flask`核心对象`app`的,所以需要将`flask`核心对象`app`传入到异步函数`send_async_email`里面去。 ~~~ def send_async_email(app, msg): with app.app_context(): try: mail.send(msg) except Exception as e: pass def send_email(to, subject, template, **kwargs): msg = Message('鱼书'+''+ subject, sender=current_app.config['MAIL_USERNAME'], recipients=[to]) msg.html = render_template(template, **kwargs) thr = Thread(target=send_async_email, args=[current_app, msg]) thr.start() ~~~ 我们用以上代码进行断点调试。 ![](https://ws4.sinaimg.cn/large/006tNc79gy1fvbp750y7bj31kw0n5n5h.jpg) ![](https://ws4.sinaimg.cn/large/006tNc79gy1fvbpbinvv7j31kw0fcq8n.jpg) 我们看到调试结果,在`send_mail`中运行的时候`current_app`是有值的`<Flask 'app'>`,但是在异步线程里,确是`unbound`无值的。 这里考察到两个知识点: * 是否真正理解`current_app` * 是否真正理解线程隔离 关于`current_app`,很多同学就把它直接**等同于**`flask`核心对象`app`,也就是说很多同学认为`current_app`和实例化的`flask`核心对象`app`是一摸一样的,实际上**是不一样的**!! `current_app`是一个**代理对象**`LocalProxy`。 * 我们实例化一个`flask`核心对象`app`,把它赋值给一个变量`app`,那么**无论你在任何地方引用这个`app`,它永远都是`flask`核心对象`app`**; * `current_app`的机制不一样,每次访问`current_app`变量的时候都会去`_app_ctx_stack`栈中读取栈顶元素,强调的是**每一次去读取`current_app`的时候**。我们在`send_mail`中访问了`current_app`,在异步函数`send_async_email`中我们又一次去访问了`current_app`,这两次访问`current_app`,可能又一次栈顶是有元素的,可能另外一次栈顶是没有元素的。比如我们在`send_mail`中访问`current_app`的时候栈顶就是有元素的,而我们在异步函数`send_async_email`中访问`current_app`的时候栈顶是没有元素的,这就导致了会出现`unbound`的状态。 但是如果我们在`send_mail`里直接实例化一个`flask`核心对象`app`,将它赋值给一个`app`变量,再将这个`app`变量传入异步函数`send_async_email`就不会出现`unbound`的情况。因为这时,任何情况下访问该`app`变量永远都是指向一个确定存在的`flask`核心对象的。 进一步揭示为什么在`send_mail`中访问`current_app`代理对象是有值的,而在异步函数`send_async_email`中访问`current_app`代理对象是无值的? 这两个函数最大的区别在于`send_async_email`启动了一个新的线程,说到线程就很容易想到`LocalStack`,也就是说`send_mail`中的线程`id`号与`send_async_email`线程`id`号是不同的。 更加准确的说,由于线程隔离, * 在`send_mail`中:`current_app`一定是有值的 * 在`send_async_email`中:`current_app`一定是无值的 ![](https://ws4.sinaimg.cn/large/006tNbRwgy1fvbpmd164kj31kw0pa1ky.jpg) 因为`send_mail`是在视图函数中调用的,调用视图函数的话,一定是有一个请求进来。请求进来之后`Request Context`要入栈,入栈之前`flask`会去检测一下另外的一个栈`_app_ctx_stack`中有没有`AppContext`,如果没有的话,`flask`框架会负责把`AppContext`自动推入到`_app_ctx_stack`栈中去,所以说当`current_app`来访问`_app_ctx_stack`的栈顶元素的时候一定是有值的。但是如果你重组线程,启动了一个新的线程,在新的线程里由于线程隔离的作用,这些栈都是空的,又没有人或者说没有代码帮助你把`AppContext`推入栈中,所以说你用`current_app`引用栈顶元素的时候一定会得到一个`unbound`的在状态。 问题的关键在于我们从`send_mail`中往`send_async_email`中传入的是一个代理对象,不是一个真实的`flask`核心对象,代理对象去查找`flask`核心对象的时候是需要根据线程的`id`号查找的,由于我们改变了线程的`id`号,`current_app`在另外的线程中就找不到**真实的`flask`核心对象**。 那么我们换一种思路,直接在`send_mail`中取到**真实的`flask`核心对象**,并且把`flask`真实的核心对象当做参数传入`send_async_email`中去就可以了。 **真实的`flask`核心对象**在任何线程中都是存在的,**代理对象**是受到线程`id`影响的,因为代理对象本身就具有线程隔离的属性。 那么接下来的问题就是如何在`send_mail`中拿到**真实的`flask`核心对象**? 使用`current_app`的`_get_current_object`方法,代码如下 ~~~ app = current_app._get_current_object() ~~~ > 七月心语: > > 异步编程并不是大家想的那么简单的,它会引起各种各样的问题,比如上面遇到的问题,如果说你不熟悉异步编程,你也不了解`flask`核心机制的话,就很难解决这个问题。而且这只是发送一个电子邮件,业务逻辑极其简单,都会有这么困哪的问题要解决,可想而知,在复杂的项目中用异步编程是多谋头疼。所以说我的原则就是,如果你对性能要求不高,建议都是用同步编程,如果确实对性能要求高的话,首先应该是先考虑各种各样的优化,实在是优化到极限之后再考虑**异步编程**。 > > 建议大家不要忙不的追求并发和异步编程,绝大多数的同学做很多的项目其实都用不到并发和异步编程,你首先应该考虑的是在同步的情况下能否很好的进行优化,实在优化不了再去考虑并发处理和异步编程。 ##### 异步线程参数传递的方法 如果我们想向另一个线程里的目标函数传递参数的话,传递的方法就是在`Thread`构造的时候加上一个关键字参数`args=[]`,`args=[]`是可以接收一组参数的列表。 ### 鱼漂业务逻辑与Drift模型 ![](https://ws4.sinaimg.cn/large/006tNbRwgy1fvc82c8wp2j31kw0n3zvl.jpg) 实际上鱼漂急速一个交易的过程,交易过程中的信息基本有请求者信息、赠送者信息、书籍信息、邮寄信息四大类。 我们在`models`层新建`drift`文件 ~~~ from sqlalchemy import Column, Integer, String, ForeignKey from sqlalchemy.orm import relationship from app.models.base import Base class Drift(Base): id = Column(Integer, primary_key=True) # 请求者信息 requester_id = Column(Integer) requester_nickname = Column(String(20)) # 赠送者信息 gifter_id = Column(Integer) gift_id = Column(Integer) gifter_nickname = Column(String(20)) # 书籍信息 isbn = Column(String(13)) book_title = Column(String(50)) book_author = Column(String(50)) book_img = Column(String(50)) # 邮寄信息 recipient_name = Column(String(20), nullable=True) address = Column(String(100), nullable=True) mobile = Column(String(20), nullable=True) message = Column(String(200)) # requester_id = Column(Integer, ForeignKey('user.id')) # requester = relationship('User') # gift_id = Column(Integer, ForeignKey('gift.id')) # gift = relationship('Gift') pass ~~~ #### 利用**数据冗余**记录历史状态 `Drift`所有内容都是平铺的,并没有出现模型关联,为什么要这样设计?这样设计有什么好处? * 模型关联 * 优点:每次查询时,关联模型的信息都是最新的 * 缺点:没有忠实记录交易状态信息 * 缺点:关联查询需要查询多张表,查询速度较慢 * 缺点:耗费大量数据库资源 * 模型不关联 * 优点:查询次数少 * 优点:忠实记录了交易状态,保证历史信息不变 * 缺点:数据冗余 * 缺点:可能导致数据的不一致 `Drift`模型是用来记录交易的,记录的是历史交易状态,一般对于具有记录性质的字段,不使用模型关联。比如日志的记录,也是直接记录不使用模型关联。 #### 设计交易状态 `Drift`里如何设计表示交易状态的信息? 对于状态而言,最好的解决方法是枚举类型。 ~~~ from enum import Enum class PendingStatus(Enum): """ 交易的4种状态 """ Waiting = 1 Success = 2 Reject = 3 Redraw = 4 ~~~ 枚举的调用: 在`Drift`模型中搜索当前用户已经成功索要到的书籍数量。判定条件: * 用户为当前用户 * 成功索要到 成功就是表明`Drift`模型的状态,这里使用枚举`PendingStatus.Success`,直接可以从英文字面意思看出鱼漂的状态,完美`coding`! ~~~ success_receive_count = Drift.query.filter_by(requester_id=self.id, pending=PendingStatus.Success).count() ~~~ > `first_or_404()`对应的是`first()` > > `get_or_404()`对应的是`get()` > > 二者用法相同。 #### 检测能否发起交易 * 自己不能向自己索要书籍 ~~~ def is_yourself_gift(self, uid): return True if self.uid == uid else False ~~~ * 鱼豆的必须足够(大于1) * 每索取两本书,自己必须送出一本书 ~~~ def can_send_drift(self): if self.beans < 1: return False success_gifts_count = Gift.query.filter_by( uid=self.id, launched=True).count() success_receive_count = Drift.query.filter_by( requester_id=self.id, pending=PendingStatus.Success).count() return True if floor(success_gifts_count / 2) <= success_receive_count else False ~~~ > 小技巧: > > 从数据库里查出来的原始数据,需要用一个`ViewModel`将其适配为页面需要的数据形式。 > **知识点**: > > `wtforms`所有的`Form`对象提供了一个快捷的方法,可以帮助我们直接把`Form`下面所有的字段拷贝到`Drift`模型中来,只需要调用`Form`对象的`populate_obj`方法,并且将我们要复制的目标对象传入到参数列表中,就可以实现相关字段的复制了。 > > ~~~ > drift_form.populate_obj(drift) > > ~~~ > > 注意:使用`populate_obj`的话,必须要确保两个对象中定义的字段名称是相同的。(当前示例中`Drift`模型和表单的字段名字一致,可以使用) #### 数据库**或**关系查询 查询数据库中赠送者**或**索要者为当前页用户的`Drift`: 一般情况我们很自然的会像下面这样写,但是实际上这样是错误的,这样写依旧是**且**关系查询,一个结果都查不到。 ~~~ drift = Drift.query.filter_by(requester_id == current_user.id, gifter_id == current_user.id)).order_by( desc(Drift.create_time)).all() ~~~ 解决方案 * `first`方法 * `sqlalchemy`的`or_`方法,将条件传入 注意:`requester_id`和`gifter_id`前面都需要加上模型名称,并且使用`==` ~~~ from sqlalchemy import or_, desc drift = Drift.query.filter(or_( Drift.requester_id == current_user.id, Drift.gifter_id == current_user.id)).order_by( desc(Drift.create_time)).all() ~~~ #### 构建`DriftViewModel` 模型里的字段都是要在页面显示出来的字段。 难点:需要根据数据的状态调整对应的值。 ![](https://ws3.sinaimg.cn/large/006tNbRwgy1fvex5rb2ssj31kw0x41kx.jpg) 解决方案: * 将不同状态分类,分别显示在两栏 * 显示在一栏,代码复杂,需要做逻辑判断,可以在前端做也可以在后端做好再返回数据 ~~~ from app.libs.enums import PendingStatus class DriftViewModel: def __init__(self, drift, current_user_id): self.data = {} self.__parse(drift, current_user_id) def __parse(self, drift, current_user_id): # 确定当前用户是请求者还是赠送者 you_are = self.requester_or_gifter(drift, current_user_id) pending_status = PendingStatus.pending_str(drift.pending, you_are) r = { # 当前用户信息 'you_are': you_are, 'operator': drift.requester_nickname if you_are != 'requester' else drift.gifter_nickname, 'status_str': pending_status, # 鱼漂信息 'drift_id': drift.id, 'date': drift.create_datetime.strftime('%Y-%m-%d'), # 书籍信息 'book_title': drift.book_title, 'book_author': drift.book_author, 'book_img': drift.book_img, # 收件人信息 'recipient_name': drift.requester_nickname, 'mobile': drift.mobile, 'address': drift.message, 'message': drift.message, # 交易信息 'status': drift.pending } @staticmethod def requester_or_gifter(drift, current_user_id): if drift.requester_id == current_user_id: you_are = 'requester' else: you_are = 'gifter' return you_are ~~~ * `Drift`创建时间需要做转换,数据库里的时间戳转换为页面显示的时间: ~~~ 'date': drift.create_datetime.strftime('%Y-%m-%d') ~~~ * 判断当前用户是谁: ~~~ you_are = self.requester_or_gifter(drift, current_user_id) 'operator': drift.requester_nickname if you_are != 'requester' else drift.gifter_nickname, ~~~ 对于需要判断的状态: * 可以传到前端判断 * 也可以在后端判断好再传数据 > 小技巧: > > 在判断当前用户的时候需要使用到**当前用户id**,我们可以在导入**当前用户id**`current_user.id`, > > ~~~ > if drift.requester_id == current_user.id: > > ~~~ > > 这样写是可以的,但是不推荐这样写,不建议在`ViewModel`中使用`current_user`。 > > **因为**这是一个**面向对象类设计**的原则,`current_user`直接导入进来会破坏类的封装性,会让`DriftViewModel`类与`current_user`形成非常紧密的耦合,会让`DriftViewModel`永远离不开`current_user`,也就是说你在使用`DriftViewModel`的地方都必须导入`current_user`,这是非常尴尬的事情,所以我们就不能将`DriftViewModel`用在一个没有`current_user`的环境里。 > > **所以不能让对象从类里凭空蹦出来,我们可以使用传递参数的形式将需要的内容传递进来,这样就能确保类具有良好的封装性(保证了类的独立性,也增强了类的灵活性)。** * 添加鱼漂的状态属性: ~~~ from enum import Enum class PendingStatus(Enum): """ 交易的4种状态 """ Waiting = 1 Success = 2 Reject = 3 Redraw = 4 @classmethod def pending_str(cls, status, key): key_map = { 1: { 'requester': '等待对方邮件', 'gifter': '等待您邮寄' }, 2: { 'requester': '对方已邮寄', 'gifter': '您已邮寄' }, 3: { 'requester': '对方已拒绝', 'gifter': '您已拒绝' }, 4: { 'requester': '对方已撤销', 'gifter': '您已撤销' } } return key_map[status][key] pending_status = PendingStatus.pending_str(drift.pending, you_are) # 定义 'status_str': pending_status # 调用 ~~~ > `Python`是没有`switch case`语句的,如果要模仿`switch case`的话,可以使用**双层字典**的方式来模拟。示例如上代码。 #### 三种类模式的总结与对比 对比总结三种`ViewModel`:`BookViewModel`、`MyGifts`、`DriftViewModel`,这三种`ViewModel`各有自己的特色。 * 共同特点: * 既有单体,也有集合 > 七月心语: > > 根据多年的经验,这不是一个特例,几乎在写任何业务项目的时候,都是要面临单体和集合的概念的。 > > 建议:单体、集合做区分。单体是单体,集合是集合。 1. `BookViewModel` ~~~ class BookViewModel: def __init__(self, book): self.title = book['title'] self.publisher = book['publisher'] self.author = '丶'.join(book['author']) self.pages = book['pages'] or '' self.price = book['price'] self.summary = book['summary'] or '' self.image = book['image'] self.isbn = book['isbn'] self.pubdate = book['pubdate'] self.binding = book['binding'] @property def intro(self): intros = filter(lambda x: True if x else False, [self.author, self.publisher, self.price]) return ' / '.join(intros) class BookCollection: def __init__(self): self.total = 0 self.books = [] self.keyword = '' def fill(self, yushu_book, keyword): self.total = yushu_book.total self.books = [BookViewModel(book) for book in yushu_book.books] self.keyword = keyword ~~~ 单本的`BookViewModel`和书籍集合的`BookConnection`是最典型的、最基础的`ViewModel`的构建方式。我们是把`BookViewModel`当做一个非常标准的类来处理的,它拥有他的实例属性。 * 优点:具有良好的扩展性 2. `MyGifts` ~~~ from collections import namedtuple from app.view_models.book import BookViewModel # MyGift = namedtuple('MyGift', ['id', 'book', 'wishes_count']) class MyGifts(): def __init__(self, gifts_of_mine, wish_count_list): self.gifts = [] self.__gifts_of_mine = gifts_of_mine self.__wish_count_list = wish_count_list self.gifts = self.__parse() def __parse(self): tem_gifts = [] for gift in self.__gifts_of_mine: my_gift = self.__matching(gift) tem_gifts.append(my_gift) return tem_gifts def __matching(self, gift): count = 0 for wish_count in self.__wish_count_list: if wish_count['isbn'] == gift.isbn: count = wish_count['count'] r = { 'id': gift.id, 'book': BookViewModel(gift.book), 'wishes_count': count } return r # my_gift = MyGift(gift.id, BookViewModel(gift.book), count) # return my_gift ~~~ `gift`这里只有集合`MyGifts`,没有单体的`ViewModel`。为什么会是这样的呢?其实还是有单体概念的,只不过我们没有为单体单独定义一个类对应**单体概念**,而是把**单体**的概念隐藏到**字典**里面去,直接返回了字典。 这种做法的 * 优点:字典结构使用方便,少写了一些代码(少定义了一个类) * 缺点:扩展性差(没有办法扩展) 业务是多变的,如果未来需要**特别地**处理单体`Gift`的时候,字典是没有办法扩展的。 3. `DriftViewModel` ~~~ from app.libs.enums import PendingStatus # 处理一组鱼漂数据 class DriftConnection: def __init__(self, drifts, current_user_id): self.data = [] self.__parse(drifts, current_user_id) def __parse(self, drifts, current_user_id): for drift in drifts: temp = DriftViewModel(drift, current_user_id) self.data.append(temp.data) # 处理单个鱼漂数据 class DriftViewModel: def __init__(self, drift, current_user_id): self.data = {} self.__parse(drift, current_user_id) def __parse(self, drift, current_user_id): # 确定当前用户是请求者还是赠送者 you_are = self.requester_or_gifter(drift, current_user_id) pending_status = PendingStatus.pending_str(drift.pending, you_are) r = { # 当前用户信息 'you_are': you_are, 'operator': drift.requester_nickname if you_are != 'requester' else drift.gifter_nickname, 'status_str': pending_status, # 鱼漂信息 'drift_id': drift.id, 'date': drift.create_datetime.strftime('%Y-%m-%d'), # 书籍信息 'book_title': drift.book_title, 'book_author': drift.book_author, 'book_img': drift.book_img, # 收件人信息 'recipient_name': drift.requester_nickname, 'mobile': drift.mobile, 'address': drift.message, 'message': drift.message, # 交易信息 'status': drift.pending } return r @staticmethod def requester_or_gifter(drift, current_user_id): if drift.requester_id == current_user_id: you_are = 'requester' else: you_are = 'gifter' return you_are ~~~ `DriftViewModel`里有一个很典型的特征,没有像在`BookViewModel`中那样将所有字段全部定义出来,而是使用`self.data = {}`字典的形式。 * 优点:即单独定义了单体的概念,具备良好的扩展性,又具备了字典方便的特性这种形式 * 缺点:可读性较差 代码维护者不能简洁明了的看出类的属性。 对于以上三种`ViewModel`推荐使用`BookViewModel`,不推荐使用`MyGifts`。 #### 撤销/拒绝鱼漂 我们在写`redraw_drift`视图函数的时候,会出现问题(拒绝视图函数`reject_drift`和撤销操作基本一致) ~~~ @web.route('/drift/<int:did>/redraw') @login_required def redraw_drift(did): with db.auto_commit(): drift = Drift.query.filter_by(id=did).first_or_404() drift.pending = PendingStatus.Redraw return redirect(url_for('web.pending')) # 这里最好是写成ajax的,来回重定向消耗服务器资源 ~~~ 调试之后我们会发现一个非常严重的问题,`PendingStatus.Redraw`对应的数字是`4`,但是我们却发现执行了这样一个赋值操作之后,`drift.pending`的值被赋值成了`0`,很显然这是不对的。原因在于**枚举**并不真正等同于数字,你把枚举类型直接赋值给数字的`drift.pending`这是不对的,这个错误是非常致命的。同样我们之前在`User`模型中使用枚举的用法也是错误的。![](https://ws2.sinaimg.cn/large/006tNbRwgy1fvfr56tzg1j31kw07ewic.jpg) 要解决这个问题,我们总共有三种方案: 1. 使用`PendingStatus.Redraw.value` 这种用法将直接获取`Redrew`真正所对应的数字 2. 不适用`PendingStatus.Redraw`,直接使用`4`为`drift.pending`赋值 ~~~ drift.pending = 4 ~~~ 3. 我们之前还有一个很不好的写法,之前我们在写`PendingStatus`枚举的时候,我们给它增加了一个`pending_str`方法,在方法里使用的双层字典,里面是`1、2、3、4`的数字,感觉非常不好,别人看你代码的时候需要对照着上面的代码理解`1、2、3、4`表示的状态,不能一眼看出状态。 那怎么解决这个问题呢? 我们可以再`drift`模型里为`pending`属性添加一个`@property`定义一个`getter`方法,首先将`pending`字段添加下划线,为`drift`模型添加一个`pending`属性,这个属性内部是可以写一个逻辑的,这个逻辑就是读取`_pending`,`_pending`是`SmallInteger`类型的(数字),但是我们可以在读取的时候转化成**枚举类型**,**转化的方式就类似于在实例化对象时候的操作方法一样**,把`PendingStatus`当做是一个对象,然后把`self._pending`传入到构造函数中去。这里是**类似**,并不代表着这里是构造函数。通过这样操作之后,**`drift.pending`操作返回的就不是数字类型了,而是枚举类型**。 ~~~ _pending = Column('pending', SmallInteger, default=1) @property def pending(self): return PendingStatus(self.pending) # 可以理解为就是pending的一个getter方法 ~~~ 同样,我们再给`pending`定义一个`setter`方法。 ~~~ @pending.setter def pending(self, status): self._pending = status.value # 定义pending的setter方法 ~~~ `setter`方法传入进来的参数其实是枚举类型的,正好与`getter`方法相反,`getter`是把数字类型转换成枚举类型,`setter`方法则是把枚举类型转换成数字类型,转换的方法就是在枚举类型后面加上`.value`获取枚举状态代表的数字状态。 有了`getter`和`setter`方法之后我们就可以对代码进行修改了,我们改变鱼漂状态的时候就可以直接使用`PendingStatus.redraw`了,由于`setter`的存在,最终的`self.pending`将会被赋值成数字类型。 ![](https://ws1.sinaimg.cn/large/006tNbRwgy1fvfs46ldpzj314m0awn0f.jpg) 这里保持了**枚举**优雅的写法,同时我们也可以将`enums`文件夹中直接使用数字不好的写法改过来,现在这种数字的写法要求外部调用`pending_str`函数的时候传递进来的`status`是个数字。我们来看下我们之前的调用`pending`的时候,![](https://ws1.sinaimg.cn/large/006tNbRwgy1fvfsbs7pnxj31kw0ac0wt.jpg) 所以我们`pending_str`函数里的设计就不再需要`1、2、3、4`这种数字了,而是可以改成更加易读的变量的名字,在外部调用的时候我们都知道使用`PendingStatus.Waiting`等来调用,那么在`PendingStatus`类的内部该如何调用呢?很简单,将`Waiting`、`Success`、`Reject`、`Redraw`当做是类变量,使用`cls.Waiting`、`cls.Success`、`cls.Reject`、`cls.Redraw`来调用,代码如下: ~~~ from enum import Enum class PendingStatus(Enum): """ 交易的4种状态 """ Waiting = 1 Success = 2 Reject = 3 Redraw = 4 @classmethod def pending_str(cls, status, key): key_map = { cls.Waiting: { 'requester': '等待对方邮件', 'gifter': '等待您邮寄' }, cls.Success: { 'requester': '对方已邮寄', 'gifter': '您已邮寄' }, cls.Reject: { 'requester': '对方已拒绝', 'gifter': '您已拒绝' }, cls.Redraw: { 'requester': '对方已撤销', 'gifter': '您已撤销' } } return key_map[status][key] ~~~ > 七月心语: > > 很多人说动态语言不容易读懂、不容易维护、非常乱,其实不是这样的。动态语言只不过给了你一种写出很随意代码的能力,让你编码能够更加轻松,但是这并不代表着我们就可以随便或者胡乱的去写代码,该遵守的编程基本素养还是要遵守的,这样才能很好的保证代码的可读性,也可以让代码更容易维护。很多同学会发现动态语言写代码非常的快,但是时间长了之后,这个代码你再去看的时候,你就根本看不懂自己在写什么了。我觉得这个问题不应该让动态语言来背锅,该背的锅还是需要自己来背,还是要培养自己的编程素养。其实我这些年写代码,静态语言写了很多,动态语言也写了很多,我的总体的感受是动态语言其实比静态语言要难很多。所有说我在我的很多课程和文章里,都给大家谈到过,第一门编程语言就目前而言,不是PHP,不是Python,而是Java或者是c#,当你的编程功底积累到一定的程度的时候,写动态语言确实是非常合适的。 #### 超权现象防范 现在主要的业务逻辑都编写完了,业务逻辑没什么问题,但是这里有一个很严重的**安全问题**,这个安全问题在很多的业务逻辑里都会存在,叫做**超权**。 ![](https://ws2.sinaimg.cn/large/006tNbRwgy1fvfxq9z8o4j31k40gq44u.jpg) 1号用户修改了不属于它的鱼漂,这是一种不安全的行为,甚至是一个非法的操作。很多同学以为`@login_required`是可以防止超权的,时间是`@login_required`是不能防止超权的。超权是需要写单独的代码做相应的处理的。 防范超权其实也很简单,在当前情况下,我们装修要多做一步验证就可以防止超权现象发生: * 验证传进来的鱼漂`did`是否属于当前用户 这样的话我们只需要在`filter_by`后面的筛选加一项条件`requester_id=current_user.id`,这样的话即使用户1改了`did`传进来那么如果当前用户与该`did`下的`requester_id`不相符的话,查询不出鱼漂的,即使能搜到,搜到的也是当前用户下的鱼漂。![](https://ws1.sinaimg.cn/large/006tNbRwgy1fvfy96ed94j31kw0ehdmo.jpg) #### 邮寄成功 代码很简单,基本与前面思路一致,思维要全面一些 ~~~ @web.route('/drift/<int:did>/mailed') def mailed_drift(did): with db.auto_commit(): drift = Drift.query.filter_by(gifter_id=current_user.id, id=did).first_or_404() drift.pending = PendingStatus.Success current_user.beans += 1 gift = Gift.query.filter_by(id=drift.gift_id, launched=False).first_or_404() gift.launched = True # wish = Wish.query.filter_by(uid=drift.requester_id, isbn=drift.isbn, launched=False).first_or_404() # wish.launched = True Wish.query.filter_by(uid=drift.requester_id, isbn=drift.isbn, launched=False).update({Wish.launched: True}) # 第二种写法 # 建议在实际开发中保持一种写法,两种写法效果相等 return redirect(url_for('web.pending')) ~~~ #### 撤销礼物 思路很简单![](https://ws3.sinaimg.cn/large/006tNbRwgy1fvg276e4mlj31kw06q412.jpg) 代码如下 ~~~ @web.route('/gifts/<gid>/redraw') @login_required def redraw_from_gifts(gid): gift = Gift.query.filter_by(id=gid, uid=current_user.id, launched=False).first_or_404() drift = Drift.query.filter_by(gift_id=gid, pending=PendingStatus.Waiting).first() if drift: flash('这个礼物正处于交易状态,请先前往鱼漂完成该交易!') with db.auto_commit(): current_user.beans -= current_app.config['BEANS_UPLOAD_ONE_BOOK'] gift.delete() return redirect(url_for('web.my_gifts')) ~~~ #### 撤销心愿 思路![](https://ws1.sinaimg.cn/large/006tNbRwgy1fvg2doqxcqj31kw06v76y.jpg) 代码如下 ~~~ @web.route('/wish/book/<isbn>/redraw') @login_required def redraw_from_wish(isbn): wish = Wish.query.filter_by(isbn=isbn, uid=current_user.id).first_or_404() with db.auto_commit(): wish.delete() return redirect(url_for('web.my_wish')) ~~~ #### 向他人赠送书籍 ![](https://ws3.sinaimg.cn/large/006tNbRwgy1fvgy1x2suij31kw0admz6.jpg) 判断赠书条件: * 当前用户拥有该本书籍的`Gift` 代码如下 ~~~ @web.route('/satisfy/wish/<int:wid>') @login_required def satisfy_wish(wid): wish = Wish.query.get_or_404(wid) gift = Gift.query.filter_by(uid=current_user.id, isbn=wish.isbn).first_or_404() if not gift: flash('你还没有上传本书,请点击"加入到赠送清单"添加此书.添加前,请确保自己可以赠送此书') else: send_email(wish.user.email, '有人想送你一本书', 'email/satisfy_wish.html', gift=gift, wish=wish) flash('已向他/她发送了一封邮件,如果他/她愿意接收你的赠送,你将收到一个鱼漂。') return redirect(url_for('web.book_detail', isbn=wish.isbn)) ~~~