第十章 书籍交易模型(1/2)



一、鱼豆

1.1 建立索要此书对应的wish模型

赠送书籍的行为,被抽象成为gift;那么,索要书籍的行为,也可以被抽象成wish。

  • 新建目录:

  • app|models|wish.py中:

​ 跟gift模型的属性基本相同

from sqlalchemy import Column, Integer, Boolean, ForeignKey, String
from sqlalchemy.orm import relationship
from app.models.base import Base


class Wish(Base):
    id = Column(Integer, primary_key=True)               # 礼物的唯一标识
    launched = Column(Boolean, default=False)            # 礼物有没有送出去?默认False,礼物没有赠送出去

    user = relationship('User')                          # 在Wish模型中,引用User这个模型,用relationship表示引用关系
    uid = Column(Integer, ForeignKey('user.id'))         # 具体是引用哪一个用户呢?     # 外键ForeignKey:数据库术语

    isbn = Column(String(15), nullable=False)            # 同一本书可以被赠送多次      # 在gift模型中,引用book这个模型

不管是赠送此书,还是索要此书,都有一个前提就是必须先确定用户的身份,即用户应该已经注册和登录完成。

注册和登录,恰好已经在上一章节搞定。万事俱备。

1.2 开始编写赠送此书的业务逻辑

app|web|gift.py中:

赠送此书的关键:要将关于该业务逻辑的两个变量放进去:谁赠送的书?赠送的哪本书?

from . import web
from flask_login import login_required, current_user  # 代表当前访问网站的用户
from ..models.base import db
from ..models.gift import Gift


@web.route('/gifts/book/<isbn>')
@login_required
def save_to_gifts(isbn):
    '''
    赠送此书
    '''
    gift = Gift()

    gift.isbn = isbn                         # 在保存之前,要将关于该业务逻辑的变量放进去:谁赠送的书?赠送的哪本书?
    gift.uid = current_user.id               # current_user:其实是实例化后的User的模型

    db.session.add(gift)                     # 实例化一个对象,并将对象保存起来
    db.session.commit()

1.3 鱼豆:每赠送一本书,系统会赠送0.5个鱼豆

下图代表的,其实是一套经济系统。

app|web|gift.py中:

编写每赠送一本书,系统会赠送0.5个鱼豆的业务逻辑

from . import web
from flask_login import login_required, current_user  # 代表当前访问网站的用户
from ..models.base import db
from ..models.gift import Gift


@web.route('/gifts/book/<isbn>')
@login_required
def save_to_gifts(isbn):
    '''
    赠送此书
    '''
    gift = Gift()

    gift.isbn = isbn                         # 在保存之前,要将关于该业务逻辑的变量放进去:谁赠送的书?赠送的哪本书?
    gift.uid = current_user.id               # current_user:其实是实例化后的User的模型
    current_user.beans += current_app.config['BEANS_UPLOAD_ONE_BOOK']        # 鱼豆的属性,在User模型中早已定义

    db.session.add(gift)                     # 实例化一个对象,并将对象保存起来
    db.session.commit()

app|setting.py中:

PER_PAGE = 15
BEANS_UPLOAD_ONE_BOOK = 0.5                # 0.5是会变化的,应该放在本配置文件中。然后被上面调用

二、思维逻辑锻炼:补写四个逻辑漏洞

2.1 四个逻辑漏洞

上小节的代码存在如下四个问题:

(1)isbn没有验证的两种情况。

验证是否是13位或10位数字形式的标准isbn?鱼书api中是否有该isbn对应的图书?

(2)缺少两种细节校验

一本书,已经作为礼物放进了赠送清单中,此时是不允许再次添加这本书到赠送清单的。

一个用户,不可能既是赠书人,又是要书人。

即,上面两个可合并为一个条件:一本书必须既不在赠送清单中,也不在心愿清单中,才能被用户添加操作。

2.2 代码补写

将验证是否是ISBN编号,视为用户的行为。

  • app|models|user.py
from sqlalchemy import Column, Integer, String, Boolean, Float
from werkzeug.security import generate_password_hash, check_password_hash
from app.libs.helper import is_isbn_or_key
from app.models.base import db, Base
from flask_login import UserMixin
from app import login_manager
from app.models.gift import Gift
from app.models.wish import Wish
from app.spider.yushu_book import YuShuBook


class User(Base, UserMixin):                        
    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 can_save_to_list(self, isbn):
        '''验证是否是ISBN编号,返回的永远是bool值'''
        if is_isbn_or_key(isbn) != 'isbn':          # 如果不是13位或10位数字形式的标准isbn,就不允许保存这样的书籍
            return False
                                                    # 如果是13位或10位数字形式的标准isbn,那么执行下面语句
        yushu_book = YuShuBook()
        yushu_book.search_by_isbn(isbn)             # 鱼书api中是否有该isbn对应的图书?
        if not yushu_book.first:
            return False

        gifting = Gift.query.filter_by(uid=self.id, isbn=isbn, launched=False).first()       # 该书是否存在赠送清单中?
        wishing = Wish.query.filter_by(uid=self.id, isbn=isbn, launched=False).first()       # 该书是否存在心愿清单中?
        if not gifting and not wishing:            # 一本书必须既不在赠送清单中,也不在心愿清单中,才能是True
            return True
        else:
            return False

做web编程与做算法的对比

对比做web编程做算法
业务需求千变万化,需要开发者具备较强的逻辑思维能力
不太需要数学知识,也不太需要算法功底学熟练之后,算法其实就是个条条框框、固定的套路
  • 在视图函数app|web|gift.py中:
from flask import current_app, flash
from . import web
from flask_login import login_required, current_user          # 代表当前访问网站的用户
from ..models.base import db
from ..models.gift import Gift


@web.route('/my/gifts')
@login_required                                  # 装饰器的顺序很重要,必须在注册路由的下面
def my_gifts():
    return 'My Gifts'


@web.route('/gifts/book/<isbn>')
@login_required
def save_to_gifts(isbn):
    '''
    赠送此书
    '''
    if current_user.can_save_to_list(isbn):    # 调用刚刚编写好的函数   # 如果是True,允许添加到清单中:方可执行以下语句
        gift = Gift()
        gift.isbn = isbn                         
        gift.uid = current_user.id               
        current_user.beans += current_app.config['BEANS_UPLOAD_ONE_BOOK']
        db.session.add(gift)                    
        db.session.commit()
    else:
        flash('这本书已添加至你的赠送清单或已存在于你的心愿清单,请不要重复添加')

can_save_to_list函数的校验,写在了User模型里,而没有写在forms验证层中。

因为,如果看作参数验证,可以放进forms验证层中;如果看作用户的行为,也可以放进User模型里。

三、事务与回滚

3.1 天然支持保证数据一致性的事务

1.事务的概念

事务是一个数据库的概念,但是放在模型中也是存在的。

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

@web.route('/gifts/book/<isbn>')
@login_required
def save_to_gifts(isbn):
    '''
    赠送此书
    '''
    if current_user.can_save_to_list(isbn):     
        gift = Gift()                                # 操作是gift数据表
        gift.isbn = isbn                         
        gift.uid = current_user.id               
        ~~~~~~~~~~~~~~~~~~~~~~~~~~
        current_user.beans += current_app.config['BEANS_UPLOAD_ONE_BOOK']        # 操作的是user数据表
        db.session.add(gift)                    
        db.session.commit()
    else:
        flash('这本书已添加至你的赠送清单或已存在于你的心愿清单,请不要重复添加')

如果程序运行到波浪线处,突然程序中断了,current_user中的鱼豆并没有加上,这会不会造成数据的异常。

为了保证数据的完整性和一致性,应该两张数据表同时操作,要么同时不操作。

这种保证数据一致性的方法,叫做事务。

2.如何在sqlalchemy中进行事务的操作?

sqlalchemy天然支持,且上面的代码已经支持了事务。

因为,其实波浪线前后的两张表,只有运行到db.session.commit()时,才会同时提交。之前,都不会提交。

3.2 回滚rollback

上面代码仍存在的问题:没有执行数据库的回滚。

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

...

@web.route('/gifts/book/<isbn>')
@login_required
def save_to_gifts(isbn):
    '''
    赠送此书
    '''
    if current_user.can_save_to_list(isbn):         
        try:
            gift = Gift()
            gift.isbn = isbn                        
            gift.uid = current_user.id             
            ~~~~~~~~~~~~~~~~~~~~~~~~~
            current_user.beans += current_app.config['BEANS_UPLOAD_ONE_BOOK']
            db.session.add(gift)                     
            db.session.commit()                         # 提交
        except Exception as e:                       # 如果在整个try语句内部出现错误,就要执行下面语句
            db.session.rollback()
            raise e                                     # 把这个异常抛出去
    else:
        flash('这本书已添加至你的赠送清单或已存在于你的心愿清单,请不要重复添加')

如果程序执行到db.session.commit()这里提交的时候出现错误,而又没有执行回滚roolback的话,那么本次和后续的sqlalchemy的插入操作也是会失败的。

因此,建议只要使用db.session.commit(),就都要用try except将其包裹起来,然后在except后面执行下db.session.rollback()回滚一下。

还是没明白没啥必须回滚?

四、解决因写回滚和抛异常多处代码重复问题

问题:

在整个项目中,执行db.session.commit()提交数据库的操作其实是很多的,也会一直重复写后面的回滚和抛出异常代码。如何避免写上面的重复代码?

4.1 上下文管理器@contextmanager装饰器的两种定义方式

1.传统的定义方式:

必须在类里面定义__enter()___exit__()这两个方法。

class MyResourse:                            # 类是一个上下文管理器,因为它里面实现了`__enter()_`与`__exit__()`这两个方法
    def __enter__(self):                     
        print('connect to resourse')         # 连接资源
        return self

    def __exit__(self, exc_type, exc_value, tb):
        print('close resourse connection')
    
    def query(self):                         # 定义一个业务方法
        print('query data')
        
        
with MyResourse() as r:                      # 用with来应用上下文管理器     # 将类的实例化对象,作为上下文表达式
    r.query()

2.简化定义的方法:

不用在类里定义__enter()___exit__()这两个方法,而是利用contextlib模块下的contextmanager装饰器

from contextlib import contextmanager


class MyResourse:                            # 该类只是一个普通的类,已不是上下文管理器了
    def query(self):                         # 定义一个业务方法
        print('query data')


@contextmanager
def make_myresaourse():
    print('connect to resourse')            # 其实是__enter__()方法里的逻辑
    return MyResourse()                     # 其实是__enter__()方法里的逻辑
    print('close resourse connection')      # 其实是__exit__()方法里的逻辑


with make_myresaourse() as r:                       
     r.query()

上面是有问题的,因为一旦整个函数return之后,后面的代码是不会执行的。这与上下文管理器期望的执行顺序是不一致的。

改正如下:

yield关键字替换return。就可保证执行顺序是:⑴-->⑵-->⑶ -->⑷

from contextlib import contextmanager


class MyResourse:                            # 只是一个普通的类,已不是上下文管理器了
    def query(self):                         # 定义一个业务方法
        print('query data')

        
@contextmanager
def make_myresaourse():
    print('connect to resourse')            # 其实是__enter__()方法里的逻辑   ⑴
    yield MyResourse()                      # 其实是__enter__()方法里的逻辑     ⑵ 
    print('close resourse connection')      # 其实是__exit__()方法里的逻辑      ⑷

    
with make_myresaourse() as r:               # 第一步       
     r.query()                              # 第二步                        ⑶         
       
    
-->
connect to resourse
query data
close resourse connection

以上带有yield关键字的函数,叫做生成器。叫什么名字不重要,重要的是知道yield关键字与return关键字的区别。

3.反思:

利用contextmanager装饰器,真的简化了上下文管理器的定义吗?

其实,不但没有简化,反而复杂了,理解也更抽象了。

但,这种写法确实有一些好处。

4.2 用打印书名小案例,揭示@contextmanager的另外功能

1.原来打印一本书的名字

print('且将生活一饮而尽')

-->
且将生活一饮而尽

2.现在,在数据库中存储的是上面这种且将生活一饮而尽,但是显示的时候想加个书名号。有没有自动加上书名号的办法?

利用contextmanager装饰器。

from contextlib import contextmanager           # 导入装饰器


@contextmanager
def book_mark():
    print('《')                       # 打印书名号的前半部分
    yield                             # yield不需要返回任何结果,因为下面的with语句中没有 as
    print('》')                       # 打印书名号的后半部分
    pass


with book_mark():                    # 用with语句,调用上面刚定义好的函数
    print('且将生活一饮而尽')
    

-->
《
且将生活一饮而尽
》

格式上默认自动换行了,利用小技巧end=修正下格式:

from contextlib import contextmanager        


@contextmanager
def book_mark():
    print('《', end='')                      
    yield                                    
    print('》', end='')                     
    pass


with book_mark():
    print('且将生活一饮而尽', end='')
    
    
-->
《且将生活一饮而尽》
知识无所谓简单、复杂,只要能解决问题就好的知识。

由上可知,contextmanager还有另外的用法:

这与上下文管理器完全没有关系,纯粹是想在要执行的核心代码前面和后面,各补充执行一段代码。

4.3 结合解决代码重复问题

第二种方法,即利用@contextmanager定义上下文管理器的方法,其实给给了我们一个机会,就是把不是上下文管理器的类,变成了一个上下文管理器。

假如,MyResourse类是自己编写的,确实可以在其内部定义__enter()___exit__()两个方法,用第一种方法。

但是,如果MyResourse类是flask提供、或第三方类库提供给我们的,你去源码中修改加上__enter()___exit__()两个方法,合适吗?明显不合适。但是,我却可以在MyResourse类的外部,将其包装成一个上下文管理器。

1.dbsqlalchemy,如何在一个第三方的类库中,为它新增加一个方法呢?

继承。子类不好命名,就修正父类的名字

app|models|base.py|中:

from flask_sqlalchemy import SQLAlchemy as _SQLAlchemy          # 既然子类不好命名,就把父类的名字修正下


class SQLAlchemy(_SQLAlchemy):                 # 自定义一个子类SQLAlchemy
    
    @contextmanager                            # 戴上一个装饰器,让下面的方法变为上下文管理器
    def auto_commit(self):                     # 在第三方的类库中,为它新增加一个方法,方便其他地方调用
        try:                                # 该行,是书名号的前半部分
            yield                             # 就像是中间枢纽
            self.session.commit()             # 以下的四行,是书名号的后半部分
        except Exception as e:
            db.session.rollback()
            raise e


db = SQLAlchemy()

class Base(db.Model):                                
    __abstract__ = True
    ...

2.调用已变为上下文管理器的方法,消除重复代码

  • 在视图函数app|web|gift.py中调用下:
...


@web.route('/gifts/book/<isbn>')
@login_required
def save_to_gifts(isbn):
    '''
    赠送此书
    '''
    if current_user.can_save_to_list(isbn):         
        
        with db.auto_commit():                    # 调用上面刚定义的方法,但本质是调用的上下文管理器
            gift = Gift()
            gift.isbn = isbn                         
            gift.uid = current_user.id               
            current_user.beans += current_app.config['BEANS_UPLOAD_ONE_BOOK']
            db.session.add(gift)                  
    else:
        flash('这本书已添加至你的赠送清单或已存在于你的心愿清单,请不要重复添加')
  • 也在视图函数app|web|auth.py中调用:
...


@web.route('/register', methods=['GET', 'POST'])    
def register():
    form = RegisterForm(request.form)             
    if request.method == 'POST' and form.validate():  
        
        with db.auto_commit()                        # 调用上面刚定义的方法,但本质是调用的上下文管理器
            user = User()                             
            user.set_attrs(form.data)         
            db.session.add(user)              
        return redirect(url_for('web.login'))         

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

4.4 两个学习建议

1.复杂知识、新语法:分离分步

遇到比较比较复杂的的知识,比如contextmanager,一定要将问题单独的分离出来,在单独的文件中编写一段非常简单的代码。

因为业务越简单,越能够有一个清晰的头脑,去关注知识或原理本身。比如,老师在学习新知识、新语法,就是这样。

2.高级编程

高级编程并不是学越来越多、越来越复杂的语法,最关键的是,用你所学过的知识,写出更好的代码。

学透《python入门与进阶》的知识点足矣:根据多年经验,选取的正是python中最实用的部分。因为,python中的高级语法是学不完的,太多了。

所以,要养成知识综合应用的能力:单个知识点很好理解,但你能不能将其组合在一起,写出更好的代码来。

比如为解决上面因写回滚和抛异常多处代码重复的问题:

  • 对生成器的理解yield
  • session.commit的理解
  • session.rollback的理解
  • 对面向对象继承的理解
培养知识综合应用能力最好的途径:看各种优秀框架的源码

五、类变量的陷阱

5.1 测试下赠送此书功能

打开搜索结果页面,测试下赠送此书这个功能:

在未登录的状态,访问http://localhost:81/book/search?q=村上春树

最终结果是报错,因为视图函数save_to_gifts()没有return返回值。

但是,此时,数据表gift中已经添加了刚才的要赠送的书籍信息。

5.2 谈下creat_time的意义:

1.creat_time用于记下该条记录的插入时间

上面四个表中的该属性均是空值。这是因为在基类模型中,并没有给属性creat_time赋值。

如何赋值?应该赋一个什么样子的值?

2.在构造函数中对类变量进行赋值

在视图函数app|models|base.py中:

在构造函数中对其进行赋值,将属性creat_time指向实例化每个对象的时间,而不是放进类变量中赋值。

from datetime import datetime


class SQLAlchemy(_SQLAlchemy):         # 自己定义一个子类
    @contextmanager
    def auto_commit(self):             # 定义一个方法
        try:
            yield
            self.session.commit()
        except Exception as e:
            db.session.rollback()
            raise e

db = SQLAlchemy()

class Base(db.Model):                                # 让base是一个基类,,同时不去创建表
    __abstract__ = True                              # 告诉SQL alchemy,我不想创建一个表数据
    status = Column(SmallInteger, default=1)         # 通过更改变量的状态,来控制这条数据是否被删除   # 1 表示此条数据存在
    create_time = Column('create_time', Integer)     # 类变量:记录该模型生成和保存的时间。此时,没有赋值。
    
    def __init__(self):
        self.create_time = int(datetime.now().timestamp())          # 将当前时间,转化为时间戳
        
        
    def set_attrs(self, attrs_dict):               
        ...
        

错误的写法:

调试模式下,creat_time指向的是开启服务器的时间:因为类变量会让所有的对象,该属性都是同一个值。

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

转载:转载请注明原文链接 - 第十章 书籍交易模型(1/2)


Follow excellence, and success will chase you.