[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`頁面)


在理清贈送清單頁面邏輯的時候,根據多年的經驗,可以想到有兩種編寫方式(如上圖)。
* 第一種,我們肯定是需要查詢出**我的所有禮物**,然后再遍歷**我的所有禮物**,根據書籍的`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. 在需要調用的時候導入一下,在哪里用就在那里導入
兩種方法都可以,七月老師更喜歡第二種方法,那就第二種吧。
> 小技巧:
>
> 當你遇到一個錯誤的時候,第一件事情就是把整個錯誤信息拖到最下面看最后的總結性的錯誤提示,絕大多數情況下,根據總結性的錯誤提示,我們其實就可以找到錯誤的原因,如果根據錯誤提示你找不到原因的話,那么就需要從下往上看具體的**錯誤堆棧信息**。
>
> 
### 重復代碼的封裝技巧
我們來談一談`MyTrades`的意義:
表面上來看`MyTrades`只是將原來的`MyGifts`、`MyWishes`合并成了一段代碼,但是嚴格意義上來說`MyTrades`實際上是`MyGifts`和`MyWishes`的基類,魚書項目中之所以沒有體現出`MyTrades`作為基類的特性(被繼承)在于`MyWishes`和`MyWishes`的業務邏輯是一模一樣的,所以我們是可以使用基類來代替這兩個子類的。
需要強調的是,如果`MyGifts`和`MyWishes`出現了行為邏輯上的差異的話,那么我們就需要單獨定義`MyGifts`和`MyWishes`去繼承`MyTrades`,然后在子類里面去實現自己特定的業務邏輯。
> 小技巧:
>
> 關于模型命名的問題,模型的命名需要經過慎重的思考,英語的重要性在這里就體現出來了,一定要找到一個比較合適而又簡潔的命名。
>
> 這樣才會讓你的代碼編寫更加輕松!
>
> 
### 忘記密碼
#### `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()`方法的內部會拋出一個異常,終止代碼運行。

最終返回的是`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__`方法。
可調用對象在普通編程的時候是用不到的,只有在做一些比較抽象的編程的時候才會發現它的用處。

> 可調用對象作為統一調用的接口的實例:
>
> ~~~
> ?# 類
> ?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`電子郵箱服務器來說會生成授權碼,就是這個授權碼 
> 七月心語:
>
> 學習編程要有選擇性的深挖,比如像這些`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`,這樣我們就可以得到一個普通的字符串。

使用`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`視圖函數的流程才能進行測試。

這個流程這么長,走一遍勉強還可以接受,但是實際寫代碼的時候可能經常需要調試,換句話說這個流程我們可能要走很多遍,這是一個相當煩人的事情。更何況我們現在面對的這個業務邏輯是我們非常熟悉的重置用戶密碼的業務邏輯,這個業務邏輯比較簡單只有兩個視圖函數,但是在大型的項目中一個業務邏輯可能涉及到十幾個視圖函數的調用流程,那么假如我們為了測試最后幾個視圖函數,卻不得不把前面每個視圖函數都走一遍,那這種調試的情況就非常的煩人。
那么有什么方法可以解決這個問題呢?
**單元測試**可以幫我們解決這個問題。
**單元測試**的優點:
* 可以確保視圖函數的正確性,甚至更小粒度函數的正確性
* 可以解決測試流程過于冗長的問題 通過編寫單元測試的測試用例是可以偽造一些數據,直接測試第二個視圖函數`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=[]`將異步函數需要的參數傳入進去。

異常:`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()
~~~
我們用以上代碼進行斷點調試。


我們看到調試結果,在`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`一定是無值的

因為`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模型

實際上魚漂急速一個交易的過程,交易過程中的信息基本有請求者信息、贈送者信息、書籍信息、郵寄信息四大類。
我們在`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`
模型里的字段都是要在頁面顯示出來的字段。
難點:需要根據數據的狀態調整對應的值。

解決方案:
* 將不同狀態分類,分別顯示在兩欄
* 顯示在一欄,代碼復雜,需要做邏輯判斷,可以在前端做也可以在后端做好再返回數據
~~~
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`模型中使用枚舉的用法也是錯誤的。
要解決這個問題,我們總共有三種方案:
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`將會被賦值成數字類型。

這里保持了**枚舉**優雅的寫法,同時我們也可以將`enums`文件夾中直接使用數字不好的寫法改過來,現在這種數字的寫法要求外部調用`pending_str`函數的時候傳遞進來的`status`是個數字。我們來看下我們之前的調用`pending`的時候, 所以我們`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#,當你的編程功底積累到一定的程度的時候,寫動態語言確實是非常合適的。
#### 超權現象防范
現在主要的業務邏輯都編寫完了,業務邏輯沒什么問題,但是這里有一個很嚴重的**安全問題**,這個安全問題在很多的業務邏輯里都會存在,叫做**超權**。

1號用戶修改了不屬于它的魚漂,這是一種不安全的行為,甚至是一個非法的操作。很多同學以為`@login_required`是可以防止超權的,時間是`@login_required`是不能防止超權的。超權是需要寫單獨的代碼做相應的處理的。
防范超權其實也很簡單,在當前情況下,我們裝修要多做一步驗證就可以防止超權現象發生:
* 驗證傳進來的魚漂`did`是否屬于當前用戶
這樣的話我們只需要在`filter_by`后面的篩選加一項條件`requester_id=current_user.id`,這樣的話即使用戶1改了`did`傳進來那么如果當前用戶與該`did`下的`requester_id`不相符的話,查詢不出魚漂的,即使能搜到,搜到的也是當前用戶下的魚漂。
#### 郵寄成功
代碼很簡單,基本與前面思路一致,思維要全面一些
~~~
@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'))
~~~
#### 撤銷禮物
思路很簡單
代碼如下
~~~
@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'))
~~~
#### 撤銷心愿
思路
代碼如下
~~~
@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'))
~~~
#### 向他人贈送書籍

判斷贈書條件:
* 當前用戶擁有該本書籍的`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))
~~~