- 項目放在github上,以下是項目地址
1.起步與紅圖
1.新建入口文件
-
在
ginger
目錄下新建入口文件ginger.py
-
實例化
Flask
對象:- 在
ginger
目錄下新建app
的包,再在其包下新建app.py
文件- 和
Flask
對象相關的初始化或操作都放入app.py
文件,使項目擁有良好拓展性 app=Flask(__name__)
- 和
- 在
-
導入項目配置文件到
Flask
對象:-
在
app
目錄下新建config
的包,再在其包下新建secury.py
(敏感配置)文件setting.py
(通用配置) -
把配置文件裝載到
app.py
中:app.config.from_object('app.config.setting') app.config.from_object('app.config.secure')
-
-
在入口文件
ginger.py
中調用appapp=create_app()
-
判斷當前文件是入口文件,調用app的run方法啟動web服務器
if __name__=='__main__': app.run(debug=True)
-
使用postman進行測試:
- 在GET中輸入localhost:5000按回車測試
- 因為還未編寫視圖函數,所以返回404
2.藍圖分離視圖函數的缺陷
-
在入口文件
ginger.py
中新建視圖函數get_user
-
利用
app.route()
裝飾器中傳遞視圖函數get_user
的URL:@app.route('/v1/user/get') def get_user(): return 'i am kikyo'
-
使用postman進行測試:
- 在GET中輸入localhost:5000//v1/user/get按回車測試
- 返回i am kikyo
-
-
為什么不在入口文件
ginger.py
中新建視圖函數- 視圖函數較多,寫在一個文件中不方便
- 不同的視圖函數有不同的作用對象,大型項目中同一個作用對象可能有很多視圖對象,應該分門別類放在不同文件中
-
所有視圖函數都是對api的操作,在
app
的包下新建api
包,在api
包下再新建v1
包-
v1
包下新建user.py
和book.py
-
把視圖函數
get_user()
和get_book()
拆分到user.py
和book.py
中-
注冊視圖函數的路由時,需要Flask的核心對象app
-
把
ginger
文件中的app
直接導入,會導致循環導入 -
使用藍圖blueprint注冊路由
- 實例化一個Blueprint(),第一個參數傳遞藍圖名稱,第二個參數指定位置信息:
book=Blueprint('book',__name__)
- 使用藍圖下的route裝飾器注冊路由:
@book.route('/v1/book/get')
- 實例化一個Blueprint(),第一個參數傳遞藍圖名稱,第二個參數指定位置信息:
-
使藍圖生效
-
把藍圖注冊到核心對象app上
-
在
app.py
文件中定義register_blueprint( )
函數注冊def register_blueprint(app): from app.api.v1.user import user from app.api.v1.book import book app.register_blueprint(user) app.register_blueprint(book)
-
在
app=create_app()
中調用register_blueprint( )
函數調用藍圖
-
-
使用使用postman進行測試看藍圖是否能拆分:
- 在GET中輸入localhost:5000//v1/user/get按回車測試
- 返回i am kikyo
-
-
藍圖Blueprint不是用來拆分視圖函數,而是一種模塊級別的拆分
-
-
-
3.創建自己的紅圖
-
在
app
下新建一個directory,命名為libs
,用於存放自己定義的模塊-
在
libs
下新建redprint.py
,定義redprint類-
在
book.py
下實例化redprint
:api=Redprint('book')
-
創建
v1
藍圖被所有紅圖公用,在v1
的___init__.py
下定義藍圖:def create_blueprint(): bp_v1=Blueprint('v1',__name__) pass
-
-
將紅圖注冊到藍圖上:
- 在藍圖定義函數create_blueprint()下使用
user.api.register(bp_v1)
- 在藍圖定義函數create_blueprint()下使用
-
把藍圖注冊到核心對象app上
-
在
app.py
文件中定義register_blueprint( )
函數注冊def register_blueprint(app): from app.api.v1 import create_blueprint_v1 app.register_blueprint(create_blueprint_v1())
-
-
-
把視圖函數中的
v1
掛載到藍圖上,把book
掛載到紅圖上 -
注冊藍圖時給其附加一個前綴:
app.register_blueprint(create_blueprint_v1(),url_prefix='/v1')
-
把視圖函數中的
book
掛載到紅圖上- 注冊紅圖時給其附加一個前綴:
user.api.register(bp_v1,url_prefix='/user')
- 注冊紅圖時給其附加一個前綴:
4.實現Redprint
-
傳入紅圖的名字
-
定義構造函數
__init_
方法傳入名字def __init__(self,name): self.name=name
-
實現
api.route()
的裝飾器-
參考blueprint的route函數,藍圖把視圖函數注冊到藍圖上,自定義的紅圖Redprint里也需要視圖函數注冊到藍圖上
-
原
blueprint.py
中的self是藍圖,但自定義的redprint.py
中的self是紅圖,route中拿不到藍圖,需要先把相關參數先保存起來#rule注冊的URL,option其他可選選擇 def route(self,rule,**options): #f裝飾器作用的函數 def decorator(f): self.mound.append((f,rule,options)) return f return decorator
-
-
將紅圖注冊到藍圖時使用了register方法,所以還需要定義register方法
-
register方法中傳入了藍圖參數,在register方法中完成視圖函數向藍圖的注冊
def register(self,bp,url_prefix=None): for f,rule,options in self.mound: endpoint = options.pop("endpoint", f.__name__) #藍圖注冊到視圖函數上 bp.add_url_rule(rule,endpoint,f,**options)
-
-
5.優化Redprint
-
注冊紅圖時url_prefix與Redprint中傳入的名字一致,可以省去
user.api.register(bp_v1,url_prefix='/user')
中的url_prefix,在Redprint中定義好def register(self,bp,url_prefix=None): if url_prefix is None: url_prefix='/'+self.name
2.自定義異常對象
1.構建client驗證器
-
在
v1
包下新建client.py
構建客戶端路由create_client()
-
from app.libs.redprint import Redprint api=Redprint('client') @api.route('/register') def create_client(): #注冊 登陸 #參數 校驗 接受參數 #WTForms 校驗表單 pass
-
-
在
libs
包下新建enums.py
定義客戶端不同方式的各種枚舉-
from enum import Enum #客戶端類型 class ClientTypeEnum(Enum): USER_EMAIL=100 USER_MOBLE=101 #微信小程序 USER_MINA=200 #微信公眾號 USER_WX=201 pass
-
-
在
app
包下新建validators
包進行客戶端參數校驗-
新建
forms.py
使用WTForms 校驗表單-
定義
ClientForm(Form)
類對客戶端表單驗證-
驗證時(
wtforms.validators
)賬號和登陸類型必須傳入(DataRequired
) -
客戶登陸類型,WTForms 表單驗證中沒有,需要自定義傳入的是枚舉類型
enums.py
中的一種#自定義客戶端類型驗證 def validate_tppe(self,value): try: client=ClientTypeEnum(value.data) except ValueError as e: raise e pass
-
-
-
2.處理不同客戶端注冊的方案
-
在
client.py
的create_client()
視圖函數中使用client驗證器進行參數的校驗- 用json獲取提交對象
- 表單的提交對象用於網頁中,json對象用於移動端中
- 用實例化的表單驗證類
ClientForm(Form)
接收獲取到的json數據對表單進行驗證 - 定義一個字典
promise
為不同的客戶端編寫不同的注冊代碼- 鍵:登陸的枚舉對象
ClientTypeEnum.USER_EMAIL
- 值:該登陸方式下用戶注冊的函數
- 鍵:登陸的枚舉對象
#client.py from flask import request from app.libs.enums import ClientTypeEnum from app.libs.redprint import Redprint from app.validators.forms import ClientForm api=Redprint('client') @api.route('/register',methods=['POST']) def create_client(): data=request.json#獲得客戶端參數 form=ClientForm(data=data)#實例化validators的forms客戶端表單驗證類 if form.validate(): promise={ ClientTypeEnum.USER_EMAIL:__register_user_by_email, ClientTypeEnum.USER_WX: __register_user_by_wx } #switch不同的客戶端編寫不同的注冊代碼 #request.args.to_dict() #表單 json #注冊 登陸 #參數 校驗 接受參數 #WTForms 校驗表單 pass #用戶用emil注冊的相關代碼 def __register_user_by_email(): pass def __register_user_by_wx(): pass
- 用json獲取提交對象
3.創建User模型
-
在
app
下新建一個models
包用來存放所有模型文件,新建用戶模型文件user.py
-
使用SQLALchemy定義
User
模型類- 定義id emil,昵稱nickname,是否管理員auth,密碼_password
- 密碼_password屬性在
SQLALchemy
中沒有定義驗證的方法,需要自定義
- 密碼_password屬性在
- 添加注冊方法
register_by_email()
#models.user.py #User模型,繼承自定義的base.py中的Base類 class User(Base): id=Column(Integer,primary_key=True) emil=Column(String(24),unique=True,nullable=False) nickname=Column(String(24),unique=True) auth=Column(SmallInteger,default=1) _password=Column('password',String(100)) #把類中定義的實例方法變成類屬性 @property def password(self): return self._password #@property對於新式類來說定義的屬性是一個只讀屬性,如果需要可寫,則需要一個@屬性.setter裝飾器裝飾該函數 @password.setter def password(self): self._password=generate_password_hash(raw) #注冊方法 @staticmethod#在對象下面再創建對象本身不合理,要用靜態方法 def register_by_email(nickname,account,secret): #在數據庫中使用auto_commit()方法新增用戶 with db.auto_commit(): user=User() user.nickname=nickname user.emil=account user.password=secret db.session.add(user) pass
- 定義id emil,昵稱nickname,是否管理員auth,密碼_password
-
-
使
SQLALchemy
生效-
在
app.py
定義register_plugin()方法- 導入
SQLALchemy
的實例化對象db
- 進行db注冊
- 創建所有數據庫的數據表
#使SQLALcjhemy生效 def register_plugin(app): from app.models.base import db#導入db db.init_app(app)#db注冊 #create_all要在app的上下文環境中進行操作 with app.app_context(): db.create_all()#創建所有數據庫的數據表 pass
- 導入
-
4.完成客戶端注冊
-
用戶注冊方法
__register_user_by_email()
-
調用
User.py
表單模型類中register_by_email()
方法完成注冊 -
通過傳入表單驗證的實例化對象
form=ClientForm(data=data)
,傳入注冊所需的account
和secret
-
nickname
無法直接從form
中獲取,json中有用戶提交的所有數據,有nickname
,但是拿到的數據沒有通過校驗,需要在form.py
中新建一個User
驗證的類#用戶注冊類驗證 class UserEmailForm(ClientForm): account = StringField(validators=[ Email(message='invalidate email') ]) secret = StringField(validators=[ DataRequired(), # password can only include letters , numbers and "_" Regexp(r'^[A-Za-z0-9_*&$#@]{6,22}$') ]) nickname = StringField(validators=[DataRequired(), length(min=2, max=22)]) #驗證賬號是否已經被注冊過 def validate_account(self, value): if User.query.filter_by(email=value.data).first(): raise ValidationError()
-
User
驗證的類UserEmailForm
接收用戶提交的數據request.json
進行驗證,傳入注冊表單模型User.register_by_email
中#用戶用emil注冊的相關代碼 def __register_user_by_email():#從form的驗證器中獲取注冊需要的參數 #request.json['nickname'] form=UserEmailForm(data=request.json) #驗證通過 if form.validate(): User.register_by_email(form.nickname.data,form.account.data,form.secret.data)
-
-
調用注冊方法
__register_user_by_email()
- 通過字典拿到
__register_user_by_email()
form.py
中將登陸類型的數字轉換成枚舉對象,用form.type.data可獲取登陸類型的枚舉對象,再通過字典[鍵]獲取鍵值promise[form.type.data]()
- 通過字典拿到
5.生成用戶數據
-
創建數據庫
ginger
:CREATE DATABASE ginger; -
在配置文件
secure.py
中用SQLALCHEMY_DATABASE_URI
連接數據庫,並用SECRET_KEY
設置密鑰保證會話安全#連接數據庫 SQLALCHEMY_DATABASE_URI = 'mysql+cymysql://root:123@127.0.0.1:3306/ginger' # 設置密鑰,保證會話安全 SECRET_KEY = '\x8d\x7f\xaf\xc8"a\xa1]c\xba\xcb\x80x\xbc\x97s'
-
REST 細節特性:輸入輸出都要是
json
格式 -
使用postman進行測試:
- 在POST中輸入localhost:5000/v1/client/register
- 在Body中選擇raw,選擇格式為json
- 在下方輸入json格式的注冊數據
- 按send發送數據,在最底下顯示success
-
查看數據添加
- 選擇數據庫use ginger;
- 顯示所有表格show tables;
- 顯示表格所有內容:select * from user;
- 數據未添加對時無法在數據庫中顯示
6.自定義異常對象
-
pycharm運行時顯示
Adress already in use
解決方法:- 用
sudo lsof -i:5000
查看哪些端口被占用 - 再使用
sudo kill (PID)
結束進程,釋放端口
- 用
-
使用postman進行測試
-
在下方輸入錯誤的json格式的注冊數據
{"account":"777@qq.com","secret":"1234567","type":99,"nickname":"***"}
-
按send發送數據,在最底下也能顯示success
-
-
在pycharm代碼左側點擊設置斷點進行debug
- 斷點1:
client.py
的form=ClientForm(data=data)
- 斷點2:
client.py
的return 'sucess'
- 斷點3:
forms.py
的客戶端類型驗證try
- 點擊
ginger.py
的debug,點擊run to cursor運行到下一個斷點,可以看到枚舉類型是99,點擊step into my code運行到raise e查看異常顯示’99 is not a vaild ClientTypeEnum’,再點擊run to cursor運行到下一個斷點可以直接運行到return ‘sucess’
而並不會在異常處中斷。 - form.validate()異常不會被wtform拋出,只會把異常信息記錄在form的error屬性中
- 斷點1:
-
校驗不通過時,手動拋出異常
-
from werkzeug.exceptions import HTTPException
進入exceptions.py查看werkzeug自帶的異常403 Not found -
繼承
HTTPException
自定義異常:在libs
下新建error_code.py
- 創建自定義的error類
ClientTypeError(HTTPException)
- 添加狀態碼和描述
#客戶端類型錯誤 class ClientTypeError(HTTPException): #401未授權 403禁止訪問 404沒有找到資源 #500服務器產生一個未知的錯誤 #200查詢成功 201創建、更新成功 204刪除成功 #301 302重定向 code=400#請求參數錯誤 description = ( "client is invalid" )
- 創建自定義的error類
-
驗證form.validate()不通過時raise ClientTypeError()
-
-
使用postman進行測試:
- 在POST中輸入localhost:5000/v1/client/register
- 在Body中選擇raw,選擇格式為json
- 在下方輸入json格式的錯誤注冊數據
- 按send發送數據,在最底下選擇preview顯示自定義的錯誤描述client is invalid,狀態碼為400
7.異常返回的標准性
- 返回信息分類
- 業務數據信息
- 操作成功提示信息
- 錯誤異常信息{‘msg’:xxx,’error_code’:xxx,’request’:url}
8.自定義json格式的APIException
-
API輸入輸出數據都必須是json格式,但繼承
HTTPException
自定義的錯誤只能輸出HTML
格式的錯誤信息,需要重寫HTTPException
-
在
libs
下新建error.py
自定義APIException(HTTPException)
類繼承HTTPException,對其重寫APIException
要有些默認的msg(錯誤信息),error_code(錯誤碼),code(錯誤狀態碼)- 有機制改變默認值
- 重寫構造函數,改變默認值
- 用if判斷是否傳了參數,用傳的參數替代默認參數
- 用super繼承父類
HTTPException
的構造方法
- 重新get_body函數,改變
HTML
內容為json格式- 字典存儲:錯誤信息,錯誤碼,訪問哪個api接口產生的(請求的http動詞+’ ‘+當前請求的URL路徑(不包括主機名和端口號))
- 通過一個靜態方法(和類本身沒有交互)用
request.full_path
拿到完整路徑,再用split去除掉問號后的路徑
- 通過一個靜態方法(和類本身沒有交互)用
- 通過
json.dumps(body)
把字典格式改成文本信息
- 字典存儲:錯誤信息,錯誤碼,訪問哪個api接口產生的(請求的http動詞+’ ‘+當前請求的URL路徑(不包括主機名和端口號))
- 重寫get_headers函數,使輸出的http頭是json
- 把
text/html
改為application/json
- 把
- 重寫構造函數,改變默認值
from flask import request, json from werkzeug.exceptions import HTTPException class APIException(HTTPException): code=500#錯誤狀態碼500服務器產生一個未知的錯誤 msg='sorry,we have a mistake 😆' error_code=999#錯誤代碼,未知錯誤 #設置構造函數,改變默認值 #headers是HTTP的頭信息 def __init__(self,msg=None,code=None,error_code=None,headers=None): #判斷傳了參數,用傳的參數替代默認參數 if code: self.code=code if error_code: self.error_code=error_code if msg: self.msg=msg #使用super繼承HTTPException的構造方法 #description是msg, super(APIException,self).__init__(msg,None) #重寫get_body def get_body(self, environ=None): """Get the json body.""" body=dict( msg=self.msg, error_code=self.error_code, #當前錯誤信息是訪問哪個api接口產生的 #當前請求的http動詞,當前請求的URL路徑(不包括主機名和端口號) request=request.method+' '+self.get_url_no_param() ) #返回json文本信息 text=json.dumps(body) return text def get_headers(self, environ=None): """Get a list of headers.""" return [("Content-Type", "application/json; charset=utf-8")] #靜態方法,類和實例化對象都能調用 #不包含?的URL @staticmethod def get_url_no_param(): full_path=str(request.full_path)#拿到完整的url的路徑 #分割?前后,只保留?前的url(?用來過濾信息) main_path=full_path.split('?') return main_path[0]
-
自定義的客戶端錯誤信息
ClientTypeError
類改為繼承APIException
- 傳遞code,error_code,msg參數
-
使用postman進行測試:
-
在POST中輸入localhost:5000/v1/client/register
-
在Body中選擇raw,選擇格式為json
-
在下方輸入json格式的錯誤注冊數據
-
按send發送數據,在最底下選擇pretty顯示自定義的錯誤的json信息,,狀態碼為400
{ "error_code": 1006, "msg": "client is invalid", "request": "POST /v1/client/register" }
-
3.修改WTForms
1.重寫WTForms
-
原來代碼Accout、nickname和secret錯誤時,依舊拋出type_error,不正確
-
其余的form.validate()時也需要拋出異常
-
把記錄在form的error屬性中異常信息拋出:在
validators
下新建basy.py
- 定義
BaseForm
繼承Form
驗證類 - 查看
client.py
需要傳輸驗證數據data,需要Form.validate進行數據驗證- 定義構造方法,通過super()繼承
Form
驗證類構造方法,傳輸數據 - 通常是重寫
validate
來更改驗證,但這里我們希望保留原有驗證方法,實現類似於validate方法- 通過super()繼承
Form
的validate方法 - 在驗證不通過時,從form的error屬性中取出錯誤信息,拋出通用參數錯誤異常
- 在
error_code.py
下定義參數異常類
- 通過super()繼承
- 定義構造方法,通過super()繼承
from wtforms import Form from app.libs.error_code import ParameterException #通用異常驗證 class BaseForm(Form): def __init__(self,data): super().__init__(data=data) '''重寫validate方法 def validate(self): pass''' #保留原有validate方法,實現類似於validate方法 def validate_for_api(self): valid=super().validate()#實現原有驗證方法 if not valid: #使不同錯誤參數傳遞不同錯誤信息,從form errors raise ParameterException(msg=self.errors)
- 定義
-
forms.py
驗證時不再繼承原有的Form
,而是繼承BaseForm
- 在
forms.py
中使用from app.validators.base import BaseForm as Form
,去除掉原來的from wtforms import Form
- 在
-
在
client.py
中不再使用原來的form.validate()
驗證,而是用改寫后的form.validate_for_api()
2.簡化調用
-
每次用
request.json·獲取post的內容,再傳到clientform中,可以在baseform中就獲取request.jso
class BaseForm(Form): def __init__(self): data=request.json super().__init__(data=data)
-
每次都先實例化
form
,再一行來form.validate_for_api()
驗證,可在baseform中返回form(return self),就可改寫為一行 -
注冊成功返回的是字符串,要再在
error_code.py
中定義成功的json格式
3.已知異常和未知異常
-
注冊信息不是json格式時,使用postman測試,返回的是HTML格式
-
異常類型
-
可以預知的,已知異常,拋出APIException
-
未知異常,全局獲取異常,統一處理
- 在入口文件
ginger.py
中定義函數framework_error()
,通過flask裝飾器@app.errorhandler(Exception)
捕獲所有基類異常,返回成已知定義的APIException
- 異常e是
APIException
,直接返回e - 異常e是
HTTPException
,轉換成APIException
所需要的json格式,再返回APIException
- 異常e是未知異常,調試模式把錯誤信息全爆出,其他情況爆出定義的have a mistake信息
- 異常e是
#捕獲全局異常 @app.errorhandler(Exception) def framework_error(e): #e可能是APIException,HTTPException,Exception if isinstance(e,APIException): return e if isinstance(e,HTTPException): code=e.code msg=e.description error_code=1007 return APIException(msg,code,error_code) else: #調試模式把錯誤信息全爆出,其他情況爆出定義的have a mistake信息 if not app.config['DEBUG']: return ServerError() else: raise e
- 在入口文件
-
-
在if和else上分別加上斷點調式
- 給錯誤的type,產生apiexception
- 將post改為get,產生httpexception
- 在
client.py
中的路由下添加一個未知錯誤1/0,點擊step into到else的未知錯誤,點擊run to coursor運行結束,報未知錯誤
4.Token與HTTPBasic驗證
1.獲取token令牌
-
API在驗證客戶賬號密碼后,返回一個token到客戶端,客戶端管理和存儲token,下一次訪問API,依舊需要攜帶token
-
Token有有效期,可以標識用戶的身份:存儲用戶的ID號,需要加密
-
在
v1
下新建token.py
編寫token的視圖函數get_token()
-
實例化validators的forms客戶端表單驗證類
-
使用字典記錄登陸類型,並通過字典的鍵獲取email類型的賬號密碼驗證函數
api=Redprint('token') #登陸 @api.route('',methods=['POST']) def get_token(): form = ClientForm().validate_for_api() #實例化validators的forms客戶端表單驗證類 promise = { ClientTypeEnum.USER_EMAIL:User.verity } identify=promise[ClientTypeEnum(form.type.data)]( form.account.data, form.secret.data )
-
需要通過過濾查詢賬號,所以在
models.user.py
中寫驗證函數- 需要在
token.py
中調用,且未用到User類中方法,用靜態方法,通過query查詢not user和not password情況下raise 異常(在error_code.py
中定義)
#獲取賬號驗證的方法 @staticmethod def vertify(email,password): user=User.query.filter_by(email=email).first() if not user: raise NotFound(msg='該用戶還未注冊') if not user.check_password(password): raise AuthFailed() return {'uid':user.id} #檢查賬號密碼 def check_password(self,raw): if not self._password: return False return check_password_hash(self._password,raw)
- 需要在
-
-
編寫生成令牌函數
generate_auth_token(uid,ac_type,scope=None,expiration=7200)
- 傳入uid用戶ID,ac_type客戶端類型,scope權限作用域,expiration過期時間
- 通過
Serializer()
實例化一個序列器,設置密鑰和有效期 - 通過
.dumps()
把字典格式改為字符串格式創建一個json網頁
#生成令牌 #uid用戶ID,ac_type客戶端類型,scope權限作用域,expiration過期時間 def generate_auth_token(uid,ac_type,scope=None,expiration=7200): #實例化一個序列化器,SECRET_KEY設置密鑰,保證會話安全,expires_in有效期 s=Serializer(current_app.config['SECRET_KEY'],expires_in=expiration) #把字典格式改為字符串格式 return s.dumps( { 'uid':uid, 'type':ac_type.value } )
-
調用令牌函數
- 方便過期時間調整,在配置文件
setting.py
中寫入過期時間TOKEN_EXPIRATION=30*24*3600
,再通過current_app.config['TOKEN_EXPIRATION']
導入 - 調用的令牌是字符串格式,API輸出都得是json格式,先生成字典格式,再通過
jsonify()
序列化將字典格式轉換成json串
expiration=current_app.config['TOKEN_EXPIRATION'] token=generate_auth_token(identify['uid'], form.type.data, None, expiration) #返回的數據需要是json格式,原來的是字符串 t={ 'token':token.decode('ascii') } #返回序列化的t,http狀態碼 return jsonify(t),201
- 方便過期時間調整,在配置文件
-
-
用postman測試
- 輸入localhost:5000/v1/token,采用post方式
- 在raw的json格式下輸入登陸需要數據{"account":"777@qq.com","secret":"123456","type":100}
- 返回加密的json格式的token數據
- 報無法獲取ClientTypeEnum
- 檢查
token.py
中promise獲取枚舉對象時是否代碼寫錯 - 檢查type類型是否寫成字符串而非數字格式
- 檢查
- 報無法獲取ClientTypeEnum
2.傳輸token
- token用處:做數據刪除或修改的接口,不希望每次都輸入賬號和密碼,攜帶未過期的令牌並驗證令牌:是否過期,是否合法
- 避免在視圖函數中寫入重復驗證代碼,在需要令牌保護的視圖函數上加上裝飾器
- 在自定義文件
libs
下新建token_auth.py
- 用戶發送攜帶有token的http請求訪問
clent.py
的get_user()
視圖函數,在執行視圖函數前調用token_auth.py
里自定義的verify_password(account,password)
函數,把token取出當做參數傳入該函數,在該函數中驗證token,合法則訪問視圖函數 - 通過實例化flask自帶的
auth=HTTPBasicAuth
,使用@auth.login_required
裝飾器攔截接口,訪問視圖函數時跳轉到使用@auth.verify_password
裝飾器的verify_password(account,password)
函數- 在視圖函數的return處,verify_password()函數中間pass處打上斷點
- 采用GET方法,body改為none,輸入localhost:5000/v1/user,沒有進return斷點,點擊run to cursor,發現進入verify_password()函數的pass斷點,因為未驗證,再點擊點擊run to cursor走完斷點,顯示Unauthorized Access
- 通過
HTTPBasicAuth
獲取賬號和密碼,把賬號和密碼放到HTTP頭- 在
verify_password()
函數的return True添加斷點,在v1.user.py
的GET視圖函數return添加斷點 - 在postman下選擇GET輸入localhost:5000/v1/user,把key:Authorization, value:basic 編碼的base64格式的賬號:密碼放到header下進行調試,賬號和密碼被傳輸進去,點擊run to cursor因return True默認token 被驗證跳到下一斷點,再點擊run to cursor運行完顯示i am kikyo
- 在
- 通過
HTTPBasicAuth
獲取token- 在postman下選擇GET輸入localhost:5000/v1/user,在Authorization的type下選擇BasicAuth,把先前的token放到username中,token被傳輸進去
- 用戶發送攜帶有token的http請求訪問
- 在自定義文件
3.驗證token
-
在
token_auth
中定義驗證token函數verify_auth_token(token)
- 實例化一個序列化器,SECRET_KEY設置密鑰,保證會話安全
- 通過try解碼token,except BadSignature和SignatureExpired驗證token是否合法或過期,拋出AuthFailed()異常
- 獲取生成令牌時生成的uid和ac_type.value,返回namedtuple的對象數據
#驗證token def verify_auth_token(token): s=Serializer(current_app.config['SECRET_KEY']) try: data=s.loads(token)#解密token except BadSignature:#通過特定異常檢測token是否合法 raise AuthFailed(msg='token不合法',error_code=1002) except SignatureExpired:#檢測token是否過期 raise AuthFailed(msg='token過期',error_code=1003) uid=data['uid'] ac_type=data['type'] return User(uid,ac_type,'')
-
在
verify_password(token,password)
中調用驗證token函數verify_auth_token(token)
,驗證成功返回True
@auth.verify_password
def verify_password(token,password):
#通過http傳遞參數
#header key:value
#key Authorization
#value basic base64(kikyo:123456)
user_info=verify_auth_token(token)
if not user_info:
return False
else:
g.user=user_info
return True
4.重寫first_or_404與get_or_404
-
在
get_user(uid)
視圖函數下通過query獲取用戶查詢不到時用get_or_404會拋出一個異常,但不是json格式,需重寫,get_or_404是query下的方法,所以在重寫的base.py
下重寫的Query類下重寫- 看basequery下怎么寫,接受id(ident)作為參數傳輸進入,通過get(ident)獲取參數,不存在拋出
error_code.py
中定義的NotFound方法
#ident傳入的id號 def get_or_404(self, ident): rv = self.get(ident) if not rv: raise NotFound() return rv
- 看basequery下怎么寫,接受id(ident)作為參數傳輸進入,通過get(ident)獲取參數,不存在拋出
5.模型對象與序列化
1.理解序列化時的default函數
- 直接在視圖函數中返回模型對象user用postman測試localhost:5000/v1/user/1會報錯,顯示它不是一個序列化對象,通常做法是把user中相關數據讀取出來,拼裝成一個字典,再通過jsonify(dict)把字典序列化,返回回去
- 打開site-packages中的flsk包下的json模塊下的init,找到
class JSONEncoder
查看default函數傳遞參數o是什么- 在
def default(self, o)
函數內部打上斷點調試,用postman測試localhost:5000/v1/user/1,點擊run to cursor運行到斷點,顯示o是傳入的User 1
模型 - 不是調用jsonify函數就會調用default函數,可以用jsonify(dict)把字典序列化,再用postman測試localhost:5000/v1/user/1會直接顯示序列化后的結果{ "age": 18, "nickname": "kikyo” },不會進入斷點
- flask知道怎么去序列化傳入的數據結構,不會調用default函數,default函數把不能夠序列化的對象,轉化成可以序列化的數據結構
- 在
2.不完美對象序列化
-
在
v1.user.py
中定義一個簡單kikyo類,擁有nickname和age屬性,使用jsonify(kikyo())序列化其實例化對象 -
在
app.py
中定義一個新的JSONEncoder類繼承flask.json中的JSONEncoder對其重寫from flask.json import JSONEncoder as _JSONEncoder class JSONEncoder(_JSONEncoder): def default(self, o): pass
-
測試是否調用自定義序列化類
-
在自定義default函數下pass設置斷點,用postman測試localhost:5000/v1/user/1,點擊run to cursor運行到斷點,會運行到系統自帶的JSONEncoder下
-
重寫Flask類,使得Flask調用重寫JSONEncoder
from flask import Flask as _Flask class Flask(_Flask): json_encoder = JSONEncoder
-
再次用postman測試localhost:5000/v1/user/1,點擊run to cursor運行到斷點,會運行到自定義的JSONEncoder下
-
-
把對象轉換成字典
-
使用對象的內置屬
o.__dict__
並在此處設置斷點,用postman測試localhost:5000/v1/user/1,dict中沒有傳入nickname和age是空的,因為類對象不會被傳入__dict__中,實例對象才會 -
在
kikyo類
中新定義一個實例化屬性class Kikyo(): name='kikyo' age=18 def __init__(self): self.gender='female'
-
再次用postman測試localhost:5000/v1/user/1,gender對象被傳入__dict__中
-
3.深入理解dict機制
-
__dict__只能獲得實例化對象的屬性,想獲得對象的全部屬性
-
將實例化的kikyo()傳入生成的dict對象中,會調用keys()函數調用其鍵,想要通過dict[‘鍵’]的方式獲得類的鍵值,需要重寫
__getitem(self,key)__
class Kikyo(): name='kikyo' age=18 def __init__(self): self.gender='female' def keys(self): return ('name','age','gender') def __getitem__(self, item): return getattr(self,item) k=Kikyo() print(dict(k))#{'name': 'kikyo', 'age': 18, 'gender': 'female'}
-
4.序列化SQLALchemy模型
-
在
return jsonify(user)
處打上斷點,用postman測試localhost:5000/v1/user/1,查看user模型下有哪些對象,對象很多,但我們只需要傳入我們需要的,因此在models/user.py
中定義keys和__getitem__函數def keys(self): return ['id','email','nickname','auth'] def __getitem__(self, item): return getattr(self,item)
-
再次用postman測試localhost:5000/v1/user/1,成功返回序列化對象
{ "auth": 1, "email": "777@qq.com", "id": 1, "nickname": "kikyo" }
5.完善序列化
-
每個模型類都要被序列化,都要寫keys和
__getitem__
方法,可以把一些公共的方法__getitem__
寫到基類base中去 -
app.py
中重寫的JSONEncoder
下的default函數過於理想化,只考慮到對象同時含有key和value的情況,需要先用hasattr()做一個判斷class JSONEncoder(_JSONEncoder): def default(self, o): if hasattr(o,'keys') and hasattr(o,'__gtitem__'): return dict(o) raise ServerError()
-
JSONEncoder
下的default函數是調用可迭代對象,對與datetime類型是不可迭代的,所以要將其改成可迭代對象strftime()- 查看是否是可迭代對象:
from collections import Iterable from datetime import datetime isinstance(datetime.now,Iterable)#False isinstance(datetime.now().strftime('%Y-%m-%d %H:%M:%S'),Iterable)#True
- 改為可迭代對象
from datetime import datetime #重寫JSONEncoder中的default函數 class JSONEncoder(_JSONEncoder): def default(self, o): if hasattr(o,'keys') and hasattr(o,'__getitem__'): return dict(o) if isinstance(o,datetime): return o.strftime('%Y-%m-%d %H:%M:%S') raise ServerError()
- 再次用postman測試localhost:5000/v1/user/1,成功返回帶時間的序列化對象
{ "addtime": "2020-11-20 11:27:38", "auth": 1, "email": "777@qq.com", "id": 1, "nickname": "kikyo" }
-
把
app.py
下經常會變的放入__init_.py
下
6.View_models對API有意義么
- 為視圖層提供個性化視圖模型
- SQLALChemy是原始的數據模型,和數據庫格式保持一致
- 前端所需要的數據格式不一定和數據庫的一樣
- 如auth為1,2在前端希望顯示普通用戶和管理員
- 需要把數據庫中1,2轉換成前端需要格式,需要在視圖函數中寫偽代碼user.auth,根據視圖函數要求將其轉換成其他格式,污染視圖函數層,不利於復用
- 可根據前端要求編寫很多個View_models,在對應的視圖函數中實例化相應的View_models,將原始數據模型傳到View_models中,View_models進行轉換,實現代碼復用
6.scope權限控制
1.刪除模型注意事項
- 刪除用戶模型不需要顯示用戶模型的各項信息,只需要返回成功刪除的信息,不是和登陸一樣返回序列化對象
- 傳入可變參數用戶uid,用戶提交數據后通過query查詢出相關數據刪除掉,刪除成功后返回在
error_code.py
中定義的刪除成功信息。
@api.route('/<int:uid>',methods=['DELETE'])
@auth.login_required
def delete_user(uid):
with db.auto_commit():
user=User.query.get_or_404(uid)
user.delete()
return DeleteSuccess()
- 采用軟刪除,並不在物理層刪除,只是更改了status的狀態,在
base.py
中采用
def delete(self):
self.status = 0
- 用postman在delete模式下測試localhost:5000/v1/user/1,此時同樣需要token令牌,不返回東西,因為定義的刪除狀態碼204是not content,把狀態碼改為202成功返回刪除信息
{
"error_code": -1,
"msg": "成功刪除",
"request": "DELETE /v1/user/1"
}
-
已經顯示刪除成功,但再次用postman測試,依舊能刪除成功,因為get_or_404只會查詢物理真實刪除的,可以改成
basy.py
中特殊處理過的filter_by
查詢,只有status為1的數據才會被查詢出來user=User.query.filter_by(id=uid).first_or_404()
-
再次用postman在delete模式下測試localhost:5000/v1/user/1,會顯示找不到該資源
{
"error_code": 1001,
"msg": "對不起,資源沒有找到",
"request": "DELETE /v1/user/1"
}
2.g變量中讀取uid防止超權
-
1號用戶攜帶令牌token刪除2號用戶資料,會造成超權
-
不能讓用戶任意的指定uid,但刪除某個用戶又需要知道該用戶uid,可以直接在token中傳遞uid,而不是在視圖函數中傳遞uid(可能1號用戶傳遞2號的uid)
-
通過flask中的g變量獲取uid
@api.route('',methods=['DELETE']) @auth.login_required def delete_user(): uid=g.user.uid
- g變量是專門保存用戶的數據,在一次請求中的所有代碼的地方,都是可以使用的,是flask程序全局的一個臨時變量,充當中間者的媒介的作用
- g變量和session的區別:session是可以跨request的,只有session還未失效,不同的request請求會獲取到同一個session,g對象不需要管過期的時間,請求一次g對象就改變一次
- 寫user時在
token_auth.py
中用的是nametuple,是一個對象,user也是一個對象,用user.uid就能拿到uid,不然需要用字典的格式user[‘uid’]
-
同一個時刻有兩個用戶同時訪問這個delete接口,g是線程隔離,不會發生數據錯亂,線程號不同,g變量指向的請求是不同的
-
把數據庫中uid為1的用戶status改回1,用postman在delete模式下測試localhost:5000/v1/user測試,成功返回刪除信息(注意視圖函數中不用帶/,帶/時測試必須在末尾加/,不然報錯,flask機制是可多寫/,不可少寫)
3.生成超級管理員賬號
- 管理員用戶可以獲得帶uid的視圖函數,非管理員用戶不可以訪問管理員用戶的視圖函數
- 生成超級用戶可以直接在數據庫中添加一個auth為2的賬戶,但密碼是加密的,不好直接添加,可以注冊一個普通用戶,把auth改成2,也可以寫一個離線的包
fake.py
from app import create_app
from app.models.base import db
from app.models.user import User
app = create_app()#調用app
with app.app_context():#在上下文環境中進行操作
with db.auto_commit():
# 創建一個超級管理員
user = User()
user.nickname = 'Super'
user.password = '123456'
user.email = '666@qq.com'
user.auth = 2
db.session.add(user)
- 不需要通過接口的方式去訪問,在本地運行
fake.py
就可以創建超級用戶
4.權限管理方案
- 生成令牌時把用戶是否是管理員的信息攜帶進去來判斷是否是管理員,生成token前會先進行賬號驗證,可以把auth信息放到
models/user.py
的賬號驗證中
#獲取賬號驗證的方法
@staticmethod
def verify(email,password):
user=User.query.filter_by(email=email).first_or_404()
if not user.check_password(password):
raise AuthFailed()
is_admin=True if user.auth==2 else False
return {'uid':user.id,'is_admin':is_admin}
- 把
token.py
調用的token的None改成is_admin
token=generate_auth_token(identify['uid'],
form.type.data,
identify['is_admin'],
expiration)
- 在生成令牌時生成is_admin參數
def generate_auth_token(uid,ac_type,is_admin=None,expiration=7200):
#實例化一個序列化器,SECRET_KEY設置密鑰,保證會話安全,expires_in有效期
s=Serializer(current_app.config['SECRET_KEY'],expires_in=expiration)
#把字典格式改為字符串格式
return s.dumps(
{
'uid':uid,
'type':ac_type.value,
'is_admin':is_admin
}
- 把
token_auth.py
驗證token后讀取到的is_admin放到生成的user的namedtuple對象中
User=namedtuple('User',['uid','ac_type','is_admin'])
#驗證token
def verify_auth_token(token):
uid=data['uid']
ac_type=data['type']
is_admin=data['is_admin']
return User(uid,ac_type,is_admin)
- 在超級管理員的視圖函數下通過g變量獲取token中的is_admin,通過if語句來判斷是否是管理員
#管理員
@api.route('/<int:uid>',methods=['GET'])
@auth.login_required
def super_get_user(uid):
#url不應該包含動詞
is_admin=g.user.is_admin
if not is_admin:
raise AuthFailed()
user=User.query.filter_by(id=uid).first_or_404()
return jsonify(user)
5.比較好的權限管理方案
- 多種管理員權限情況下不適用前面權限管理方案
- 可以把每種管理員擁有的權限分別寫到不同的表中,通過token 獲得管理員的種類,再查表得知擁有什么權限,而不是用簡單的if語句,表可以寫在MySQL,Redis和代碼中
- 查詢比較頻繁,寫在配置文件中可減少MySQL負擔
- 可以在進入相應視圖函數之前,判斷某個攜帶token請求是否有訪問某個接口的權利
- 同樣把auth信息放到
models/user.py
的賬號驗證中,其余地方也同不太好的方法的一樣
#獲取賬號驗證的方法
@staticmethod
def verify(email,password):
user=User.query.filter_by(email=email).first_or_404()
if not user.check_password(password):
raise AuthFailed()
scope='AdminScope' if user.auth==2 else 'UserScope'
return {'uid':user.id,'scope':scope}
-
在自定義的
libs
包下新建scope放權限作用域表- 定義管理員權限類AdminScope定義用戶限類UserScope
- 定義各種權限下能進入的視圖函數
- 定義一個判斷某個權限下能訪問的視圖函數
#判斷某個scope是否能訪問某個視圖函數 def is_in_scope(scope,endpoint): if endpoint in scope.allow_api: return True else: return False
-
為了實現在進入相應視圖函數之前,判斷某個攜帶token請求是否有訪問某個接口的權利,可以在token驗證函數下,先驗證當前權限,再調用判斷某個權限下能訪問的視圖函數
is_in_scope(scope,request.endpoint)#request當前請求要訪問的視圖函數
,驗證當前權限是否能訪問,當前訪問的視圖函數,不能拋出異常。 -
因為在
token_auth
函數下已經對該權限下能否訪問視圖函數做了判斷,所以user.py
下管理員用戶對權限的判斷可以刪除scope=g.user.scope if not scope: raise AuthFailed()
-
用postman測試
-
輸入localhost:5000/v1/token,采用post方式
-
在raw的json格式下輸入登陸需要數據{"account":"777@qq.com","secret":"123456","type":100}生成普通用戶的token
-
在raw的json格式下輸入登陸需要數據{"account”:"666@qq.com","secret":"123456","type":100}生成管理員用戶用戶的token
-
輸入localhost:5000/v1/user/1,采用get方式
- 在aurthorization下先采用普通用戶的token
- 在
token_auth.py
的驗證token的s=Serializer(current_app.config['SECRET_KEY'])
打上斷點,運行報錯'str' object has no attribute 'allow_api'
- 因為scope只是一個字符串,無法scope.allow_api,需要通過類的名字AdminScope得到類的對象AdminScope,可以通過globals()來實現
- 查看globals()是什么,在globals()上打上斷點運行,發現是一個字典,可以把當前模塊下所有變量都變成一個字典
def is_in_scope(scope,endpoint): scope=globals()[scope]()#將字符串的類對象實例化 if endpoint in scope.allow_api: return True else: return False
-
再次用postman驗證普通用戶,拋出禁止訪問的異常
{ "error_code": 1004, "msg": "禁止訪問,您沒有該類權限", "request": "GET /v1/user/1" }
- 再次用postman驗證管理員用戶,判斷某個scope是否能訪問某個視圖函數依舊return false,發現endpoint是
v1.super_get_user
(視圖函數注冊在藍圖上,帶了藍圖的前綴,注冊在app上就不帶),但我們定義時定義的可訪問視圖函數是allow_api=['super_get_user’]
,需要修改為v1.super_get_user
,成功返回
{ "addtime": "2020-11-20 11:27:38", "auth": 1, "email": "777@qq.com", "id": 1, "nickname": "kikyo" }
-
6.scope優化
scope.py
不同的管理者所能訪問的視圖函數,可能存在大量重復,可使用函數實現權限相加
#視圖函數權限相加
def __init__(self):
self.add(UserScope())
def add(self, other):
self.allow_api+=other.allow_api
- add函數由於沒有返回任何東西,是None,所以調用
self.add(UserScope())
返回的是None,無法再使用.add()增加權限,可以通過在add函數返回self實現 - 希望其他管理者也能使用add函數添加權限,將add函數改成一個基類,需要的類對其繼承
#添加權限
class scope:
def add(self, other):
self.allow_api+=other.allow_api
return self
- 可以直接把add函數改成重載運算符
__add___
,權限相加時就不用self.add(UserScope()),而是self+UserScope() - 多個權限相加時可能造成重復加,可使用set去重
#添加權限
class scope:
def __add__(self, other):
allow_api=[]#視圖函數
self.allow_api+=other.allow_api
#去重
self.allow_api=list(set(self.allow_api))
return self
-
某個管理員支持user模塊下所有視圖函數,不太適用於一個個添加,而是查看是否在該模塊列表下,因此要在管理員類下添加能訪問的模塊列表
allow_module=['v1.user’]
,在驗證某個管理員的函數下添加模塊驗證if red_name in scope.allow_module:#某個模塊名是否在用戶權限模塊名下
-
模塊名通過拿紅圖名,修改
redprint.py
中endpoint,不僅拿到視圖函數名,還要拿到紅圖名endpoint = self.name+'+'+options.pop("endpoint", f.__name__)#self.name當前紅圖名
-
通過split分割endpoint得到red_name`
splits=endpoint.split('+') red_name=splits[0]
-
-
去除掉管理員用戶可以訪問的視圖函數,使用模塊列表,再次用postman驗證管理員用戶,成功返回
{ "addtime": "2020-11-20 11:27:38", "auth": 1, "email": "777@qq.com", "id": 1, "nickname": "kikyo" }
-
想要A支持除B 中幾個視圖函數以外的其他視圖函數,可以在A的構造函數中將B的所有權限加進來,然后列個禁止列表,在判斷權限函數中,在endpoint在禁止列表中時,return false,就能實現
#用戶權限
class UserScope:
forbidden=['v1.user+super_get_user','v1.user+super_delete_user']
def __init__(self):
self+AdminScope()
#判斷某個scope是否能訪問某個視圖函數
def is_in_scope(scope,endpoint):
scope=globals()[scope]()#將字符串的類對象實例化
if endpoint in scope.forbidden:
return False