異步篇最接近Frodo的初衷了栓辜。通信與數(shù)據(jù)的內(nèi)容使用傳統(tǒng)框架的思路是相同的颅夺。而異步思路只改變了若干場景的實(shí)現(xiàn)方法拳亿。
項(xiàng)目地址
https://github.com/LouisYZK/Frodo
博客原文地址
http://zhikai.pro/
異步編程不是新鮮概念享怀,但他并沒有指定很明確的技術(shù)特點(diǎn)和路線漓库。相關(guān)概念也不是很清晰恃慧,很少有文章能細(xì)致地說明白 阻塞/非阻塞、異步/同步米苹、并行/并發(fā)糕伐、分布式、IO多路復(fù)用蘸嘶、協(xié)程 這些概念的區(qū)別與聯(lián)系良瞧。這些概念在CS專業(yè)的OS、分布式系統(tǒng)課程中可能有設(shè)計(jì)训唱,但具體實(shí)現(xiàn)層面可能鮮有涉及褥蚯。具體到Python這門語言,我閱讀了很多工業(yè)界况增、python屆的工作者(或者稱為pythonista們)寫的文章赞庶,下面兩篇是最值得閱讀的:
小白的 asyncio :原理、源碼 到實(shí)現(xiàn)(1) - 閑談后的文章 - 知乎澳骤; 當(dāng)然標(biāo)題是作者在自謙歧强。該文作者結(jié)合CPython中asyncio標(biāo)準(zhǔn)源碼、函數(shù)棧幀的源碼和python函數(shù)上下文源碼實(shí)現(xiàn)講述了python異步的設(shè)計(jì)原理为肮,并手寫了一個簡易版的事件循環(huán)和asyncio-future對象摊册。
深入理解 Python 異步編程(上);這篇文章寫于2017年颊艳,當(dāng)時asyncio還沒成為標(biāo)準(zhǔn)庫茅特。這篇文章大篇幅使用python和linux的epoll接口一步步實(shí)現(xiàn)了單線程異步IO,最后引出了asyncio的事件循環(huán)棋枕,證實(shí)了其便捷性白修。作者規(guī)劃還有中下篇講述asyncio的原理,可是目前還沒等到下文重斑。作者安放文章代碼的倉庫已經(jīng)累計(jì)了數(shù)十條催更的issue兵睛。
基本問題
還記得我們再「通信篇」繪制的時序圖嗎?用它表示一次用戶執(zhí)行的邏輯是沒問題的,但實(shí)際實(shí)現(xiàn)中祖很,我們真的能這樣寫代碼嗎累盗?這里有兩個基本問題:
并發(fā)訪問問題,如何實(shí)現(xiàn)多人同時訪問你的博客web進(jìn)程突琳?
如何避免io阻塞,從而充分利用cpu的時間片符相?
第一個問題做過web開發(fā)的都很熟悉了拆融,他的解決方案很多,因?yàn)檫@是軟件發(fā)展中必須面對的問題:
os級別啊终,io多路復(fù)用機(jī)制镜豹,成熟的為linux的epoll機(jī)制,
nginx
便是基于此實(shí)現(xiàn)訪問并發(fā)蓝牲。編程語言使用多線程解決趟脂,以
Flask
為例,使用本地線程解決線程安全問題例衍。編程語言使用異步編程解決昔期,以
nodejs
為例,promise
+回調(diào)的方式佛玄。python就是以asyncio
為代表的異步生態(tài)圈硼一。
第二個問題其實(shí)跟第一個問題是一個意思,把對象換成cpu即可梦抢。Frodo
解決第一個問題使用的是類似asyncio事件循環(huán)的uvloop
循環(huán)般贼,他包裝成了一個機(jī)遇ASGI
協(xié)議的web服務(wù)器uvicorn
,他可以啟動多個ASGI
標(biāo)準(zhǔn)寫的app,內(nèi)置一套事件循環(huán)實(shí)現(xiàn)并發(fā)訪問奥吩。
uvicorn main:app --reload --host 0.0.0.0 --port 8001
重點(diǎn)是Frodo
對于第二個問題的解決哼蛆,這些都是在程序細(xì)節(jié)中體現(xiàn)出的。
問題分析:哪里存在IO阻塞
我們拿「通信篇」中CRUD的通信邏輯舉例霞赫,我們先標(biāo)注出IO阻塞的地方, 然后對應(yīng)到程序設(shè)計(jì)中的環(huán)節(jié)腮介,再來思考在實(shí)現(xiàn)中怎么解決。
[圖片上傳失敗...(image-403681-1592273008953)]
圖中標(biāo)注出了三類io場景绩脆,并有的是串行的需求萤厅,有的是并發(fā)(可以并發(fā))的需求。我來分別解釋下:
第一類: 網(wǎng)絡(luò)的連接和斷開靴迫,http是基于tcp的可靠傳輸協(xié)議惕味,建立連接的過程也是耗時的io操作。數(shù)據(jù)庫的連接是網(wǎng)絡(luò)連接或套接字文件讀寫類的鏈接玉锌,也是io耗時的名挥。這些代碼主要在web中的checkpoin函數(shù),在
Frodo
的views
目錄下主守。第二類: 通信異步是指客戶端發(fā)送請求禀倔,等待數(shù)據(jù)準(zhǔn)備好到返回的過程榄融,這部分等到的時間其實(shí)是后端的數(shù)據(jù)io操作,cpu不應(yīng)被這段時間占用救湖。這部分代碼在
Frodo
的mdoels
下愧杯。第三類: 數(shù)據(jù)異步是指跟數(shù)據(jù)庫操作等待數(shù)據(jù)返回所需的時間消耗。這部分時間也應(yīng)該還給cpu鞋既。
上述的很多場景必須是串行完成的力九,比如建立數(shù)據(jù)庫連接-->數(shù)據(jù)操作-->斷開連接。也有一些場景(主要是不涉及數(shù)據(jù)一致性的場景)可以是并行的邑闺,如緩存的更新與刪除跌前,因?yàn)镵V數(shù)據(jù)庫不涉及關(guān)系的聯(lián)立,可以并行地刪除陡舅。
解決方案
第一類:連接耗時
數(shù)據(jù)庫的連接與退出同步中都會想到使用帶with
關(guān)鍵字的連接池抵乓,異步為了這一連接過程可以「被等待」或者說交出執(zhí)行權(quán)給主程序,需要使用async
關(guān)鍵字包裝一下靶衍,并實(shí)現(xiàn)異步上下文的方法__aenter__
, __aexit__
.
import databases
class AioDataBase():
async def __aenter__(self):
db = databases.Database(DB_URL.replace('+pymysql', ''))
await db.connect()
self.db = db
return db
async def __aexit__(self, exc_type, exc, tb):
if exc:
traceback.print_exc()
await self.db.disconnect()
事實(shí)上灾炭,aiomysql
已經(jīng)幫助我們實(shí)現(xiàn)了類似的功能,但很遺憾aiomysql
不能和sqlalchemy
配套使用摊灭,database
是一個簡單的異步的數(shù)據(jù)庫驅(qū)動引擎咆贬,能執(zhí)行sqlalchemy
生成的sql。
第二類:通信耗時
這點(diǎn)能否異步直覺決定了web應(yīng)用的響應(yīng)速度帚呼,異步下的checkpoint函數(shù)本身為async def
關(guān)鍵字的協(xié)程掏缎,再由uvloop
調(diào)度。對于此類函數(shù)的要求是對于阻塞操作一律使用await
等待煤杀,看個例子:
@app.post('/auth')
async def login(req: Request, username: str=Form(...), password: str=Form(...)):
user_auth: schemas.User = \
## 涉及到IO的函數(shù)需要等待
await user.authenticate_user(username, password)
if not user_auth:
raise HTTPException(status_code=400,
detail='Incorrect User Auth.')
access_token_expires = timedelta(
minutes=int(config.ACCESS_TOKEN_EXPIRE_MINUTES)
)
access_token = await user.create_access_token(
data={'sub': user_auth.name},
expires_delta=access_token_expires)
return { ... }
async def authenticate_user(
username: str, password: str) -> schemas.User:
user = await User.async_first(name=username)
user = schemas.UserAuth(**user)
if not user: return False
if not verify_password(password, user.password): return False
return user
你可能注意到了有些函數(shù)如verify_password
并沒有等待他眷蜈,因?yàn)樗怯?jì)算任務(wù),不可被等待沈自。我們只需按照邏輯把io耗時操作等待即可酌儒。
第三類:數(shù)據(jù)操作耗時
這體現(xiàn)在異步ORM
方法的設(shè)計(jì)上,database
+ sqlalchemy
的實(shí)現(xiàn)范例如下:
@classmethod
async def asave(cls, *args, **kwargs):
''' update '''
table = cls.__table__
id = kwargs.pop('id')
async with AioDataBase() as db:
query = table.update().\
where(table.c.id==id).\
values(**kwargs)
## 等待1: 執(zhí)行sql語句
rv = await db.execute(query=query)
## 等待2: 拿取數(shù)據(jù)構(gòu)造對象
obj = cls(**(await cls.async_first(id=id)))
## 等待3: 清除對象涉及的緩存
await cls.__flush__(obj)
return rv
以更新數(shù)據(jù)數(shù)據(jù)為例枯途,涉及到的等待忌怎。同步的ORM框架像pymysql
在db.execute(...)
這類方法上式不可以被等待的,直接是阻塞的酪夷,異步的寫法里要等待他的結(jié)果榴啸,帶來的好處便是等待的時間執(zhí)行權(quán)歸還主程序,使其可以處理其他事務(wù)晚岭。
并行的實(shí)現(xiàn)
異步下的并行是指很多io操作并不涉及數(shù)據(jù)一致性鸥印,可以并行處理,比如刪除沒有關(guān)系的數(shù)據(jù),查詢?nèi)舾蓴?shù)據(jù)库说,更新沒有關(guān)系的數(shù)據(jù)等狂鞋,這些都可以并行。異步中也允許這些并行潜的,借助asycio.gather(*coros)
方法實(shí)現(xiàn)骚揍,這個方法將傳遞進(jìn)去的協(xié)程都放入事件循環(huán)隊(duì)列,逐個執(zhí)行類似coro.send(None)
的操作啰挪,因?yàn)閰f(xié)程立馬退出疏咐,所以所有協(xié)程可以立馬「同時」被喚醒等待,達(dá)到并行的效果脐供。
類設(shè)計(jì)中使用的tricks
本節(jié)的內(nèi)容是在使用python異步中的一些小技巧,可以幫助我們實(shí)現(xiàn)更好的設(shè)計(jì)借跪。
將類的@property屬性序列化
序列化對象很常見政己,尤其是想在緩存中存儲對象時需要序列化。對象的有些屬性是用異步@property
完成的掏愁,跟其他屬性不同歇由,他們需要特殊的調(diào)用:
class Post(BaseModel):
...
@property
async def html_content(self):
content = await self.content
if not content:
return ''
return markdown(content)
這個property
有些是異步的,每次使用此屬性時都需要content = await post.html_content
, 而不帶async
和await
的屬性可以直接訪問content = post.html_content
果港。
這就給我們的序列化方法帶來了麻煩沦泌。 我們想讓類擁有一個知道自己有哪些異步property的功能,從而能在BaseModel
中實(shí)現(xiàn)統(tǒng)一的序列化方法(在子類分別實(shí)現(xiàn)序列化方法是不現(xiàn)實(shí)的)辛掠。
讓類附加一個partials
的屬性谢谦,存儲需要等待的property
, 對于python萝衩,控制類的行為(注意是類的創(chuàng)建行為回挽,不是實(shí)例的創(chuàng)建行為)需要改變其元類,我們設(shè)計(jì)一個叫PropertyHolder
的元類猩谊,讓他的行為控制所有數(shù)據(jù)類的生成:
class PropertyHolder(type):
"""
We want to make our class with som useful properties
and filter the private properties.
"""
def __new__(cls, name, bases, attrs):
new_cls = type.__new__(cls, name, bases, attrs)
new_cls.property_fields = []
for attr in list(attrs) + sum([list(vars(base))
for base in bases], []):
if attr.startswith('_') or attr in IGNORE_ATTRS:
continue
if isinstance(getattr(new_cls, attr), property):
new_cls.property_fields.append(attr)
return new_cls
他的功能是過濾出我們所需要的@property
, 直接付給類的properties
屬性千劈。
接下來就是改變BaseModel
的生成元類:
@as_declarative()
class Base():
__name__: str
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower()
@property
def url(self):
return f'/{self.__class__.__name__.lower()}/{self.id}/'
@property
def canonical_url(self):
pass
class ModelMeta(Base.__class__, PropertyHolder):
...
class BaseModel(Base, metaclass=ModelMeta):
...
Base
是ORM的基類,他本身的元類也被改變(意味著不是type),如果直接改變它則會讓我們的數(shù)據(jù)類型喪失ORM的功能牌捷,兩全其美的辦法是創(chuàng)建一個新的類同時繼承Base
和PropertyHolder
, 使這個類成為新的混合元類墙牌。(好繞啊,這里的套娃現(xiàn)象我也不想的暗甥,我會慢慢找到更好的方案的...)喜滨。
tricks: 類的元類如何拿到? 調(diào)用
cls.__class__
獲取他基于的元類淋袖。記住鸿市,python中類本身也是對象。他的創(chuàng)建也是受控制的。
關(guān)于fastapi
好了焰情,Frodo
第一個版本的核心設(shè)計(jì)思路已經(jīng)介紹完了陌凳,前面的敘述中,我很少提fastapi
内舟,因?yàn)楫惒絯eb本身和框架是沒關(guān)系的合敦,這套內(nèi)容換成sanic
,aiohttp
,tornado
甚至是Django
都是一樣的,只是具體的實(shí)現(xiàn)手段不同验游,比如Django
的異步是基于他自己設(shè)計(jì)的channel
實(shí)現(xiàn)的充岛。
但fastapi
也有他的特別之處,設(shè)計(jì)思想兼容并蓄耕蝉,也思考了很多崔梗,在開發(fā)中我強(qiáng)烈推薦使用的幾個地方:
數(shù)據(jù)模式
schema
的設(shè)計(jì),配套pydantic
的類型檢查垒在,讓python這門動態(tài)語言變得更加可讀蒜魄、調(diào)試更加容易、語法更加規(guī)范场躯,我相信這是未來的趨勢谈为。Depends
的設(shè)計(jì),我們曾想過把復(fù)用的邏輯封裝成類踢关、函數(shù)伞鲫、裝飾器,但fastapi
直接在參數(shù)上做文章签舞,令我驚訝秕脓,他在參數(shù)上就代替了上下文、多參數(shù)儒搭、表單參數(shù)撒会、認(rèn)證參數(shù)等。兼容同步寫法师妙,包含
WSGI
诵肛,使用同步的技術(shù)庫搭配fastapi
完全沒問題,他允許同步函數(shù)的存在默穴,原因便是他基于的ASGI
認(rèn)為自己是WSGI
的超集怔檩,應(yīng)當(dāng)兼容兩種寫法。配套swagger-doc, 后端福利蓄诽,使得你不需要花費(fèi)時間學(xué)習(xí)OpenAPI 語法便可順利做出前后端人員都能用薛训、都能理解的調(diào)試平臺和文檔,省時省力仑氛。
Frodo的三篇介紹到此就完結(jié)了乙埃,靠課余闸英、科研時間之外的空隙完成的項(xiàng)目難免漏洞百出。但一個月的戰(zhàn)線后總算是完成了第一個版本介袜。未來的目標(biāo)是星辰大海甫何,新語言的加入、多服務(wù)的拆分遇伞、虛擬化部署都需要時間的檢驗(yàn)辙喂,努力吧~!