Python-FastAPI 異步博客開發(fā)(三)異步特性篇

異步篇最接近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ù),在Frodoviews目錄下主守。

  • 第二類: 通信異步是指客戶端發(fā)送請求禀倔,等待數(shù)據(jù)準(zhǔn)備好到返回的過程榄融,這部分等到的時間其實(shí)是后端的數(shù)據(jù)io操作,cpu不應(yīng)被這段時間占用救湖。這部分代碼在Frodomdoels下愧杯。

  • 第三類: 數(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框架像pymysqldb.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, 而不帶asyncawait的屬性可以直接訪問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)建一個新的類同時繼承BasePropertyHolder, 使這個類成為新的混合元類墙牌。(好繞啊,這里的套娃現(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)辙喂,努力吧~!

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末鸠珠,一起剝皮案震驚了整個濱河市巍耗,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌渐排,老刑警劉巖炬太,帶你破解...
    沈念sama閱讀 216,651評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異驯耻,居然都是意外死亡娄琉,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,468評論 3 392
  • 文/潘曉璐 我一進(jìn)店門吓歇,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人票腰,你說我怎么就攤上這事城看。” “怎么了杏慰?”我有些...
    開封第一講書人閱讀 162,931評論 0 353
  • 文/不壞的土叔 我叫張陵测柠,是天一觀的道長。 經(jīng)常有香客問我缘滥,道長轰胁,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,218評論 1 292
  • 正文 為了忘掉前任朝扼,我火速辦了婚禮赃阀,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘擎颖。我一直安慰自己榛斯,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,234評論 6 388
  • 文/花漫 我一把揭開白布搂捧。 她就那樣靜靜地躺著驮俗,像睡著了一般。 火紅的嫁衣襯著肌膚如雪允跑。 梳的紋絲不亂的頭發(fā)上王凑,一...
    開封第一講書人閱讀 51,198評論 1 299
  • 那天搪柑,我揣著相機(jī)與錄音,去河邊找鬼索烹。 笑死工碾,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的术荤。 我是一名探鬼主播倚喂,決...
    沈念sama閱讀 40,084評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼瓣戚!你這毒婦竟也來了端圈?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,926評論 0 274
  • 序言:老撾萬榮一對情侶失蹤子库,失蹤者是張志新(化名)和其女友劉穎舱权,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體仑嗅,經(jīng)...
    沈念sama閱讀 45,341評論 1 311
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡宴倍,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,563評論 2 333
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了仓技。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片鸵贬。...
    茶點(diǎn)故事閱讀 39,731評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖脖捻,靈堂內(nèi)的尸體忽然破棺而出阔逼,到底是詐尸還是另有隱情,我是刑警寧澤地沮,帶...
    沈念sama閱讀 35,430評論 5 343
  • 正文 年R本政府宣布嗜浮,位于F島的核電站,受9級特大地震影響摩疑,放射性物質(zhì)發(fā)生泄漏危融。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,036評論 3 326
  • 文/蒙蒙 一雷袋、第九天 我趴在偏房一處隱蔽的房頂上張望吉殃。 院中可真熱鬧,春花似錦楷怒、人聲如沸寨腔。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,676評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽迫卢。三九已至,卻和暖如春冶共,著一層夾襖步出監(jiān)牢的瞬間乾蛤,已是汗流浹背每界。 一陣腳步聲響...
    開封第一講書人閱讀 32,829評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留家卖,地道東北人眨层。 一個月前我還...
    沈念sama閱讀 47,743評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像上荡,于是被迫代替她去往敵國和親趴樱。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,629評論 2 354