談下python微服務中的序列化場景

guerbai發表於2019-01-31

上一篇文章中說到了驗參,現在接著說另一個微服務中的工程性問題,序列化。
作為編寫業務的程式設計師,常被戲稱為CRUD程式設計師,會增刪改查,給個if else給個for就能混碗飯吃。此話倒不假。
在微服務體系下,工作中有時會接觸多個專案,各個service與各個gateway,由於維護人員的不同,雖然都是做CRUD的工作,但專案結構與寫法卻不盡相同。

這讓我回想起剛參加工作時加入一家很小的創業公司,一個簡單的單體應用,只有一層,flask的http request進來直接去操作DB,操作sqlalchemy的session,沒有任何的抽象,可以說沒有任何可以複用的業務程式碼,每個函式進來都是全新的世界,要開始重新探索,極其醜陋。(甚至可以開玩笑地說,這樣有了bug可以將bug只控制在那一個函式以內,不會存在遷一發而動全身的風險。。。)

所以我覺得,將Python微服務中的常見業務程式碼做到規範化並且儘可能的優雅其實是並不容易的,Python靈活歸靈活,但太靈活卻容易寫出太飄的程式碼,無法與Java的工程性相提並論。

程式碼結構層面其實也不用多說,上篇文章中提到過,handler,service,model三層結構,handler進行驗參後調service,service可以調service和model,model不可以互相調,驗參也是一個比較重要的環節。
這篇來說一下序列化問題,總結一下一個最簡單的由前端發起的http請求到gateway再由thrift rpc到service再到db總共要經歷多少次序列化和反序列化,以及對Python序列化庫marshmallow的應用實踐,搞清楚一個簡單的CRUD的請求中間到底經過了多少層的轉換以及每一次的轉換是為了什麼。


其實就序列化這個描述本身而言,並沒有說清楚從哪種型別到哪種型別是序列化,從而在各種場景下都會用到這個詞,常常會讓人感到混淆。
比如,Python有個pickle庫,眾所周知是用於將任意Python物件以檔案的形式存入磁碟,它有的序列化函式dump是將Python物件轉化為寫到磁碟上的二進位制檔案。
json是Python的內建處理json的包,用於序列化json。但這又是什麼意思呢?此時的序列化是說將Python的dict型別轉化為符合json規範的字串型別。
而marshmallow的序列化,卻是將業務物件轉化為Python的dict結構,這就很容易搞亂了,在json的語境來說dict是序列化的輸入,而在這裡卻成了輸出。
這就一度搞得我很亂,一度我只能通過dump與記憶來區分各個序列化場景。但不知為什麼在某層的某個節點要這樣轉一下。

在定義不清的時候,去查維基的標準定義往往會很有用。

序列化(serialization)在電腦科學的資料處理中,是指將資料結構或物件狀態轉換成可取用格式(例如存成檔案,存於緩衝,或經由網路中傳送),以留待後續在相同或另一臺計算機環境中,能恢復原先狀態的過程。

所以,一個序列化過程,並不確定它的輸入方與輸出方究竟是什麼型別,而是要根據情況而定。我的感受是,越接近業務本身,越接近Python語言本身,離序列化的輸入方就越近;越與業務無關、與語言無關,更接近某協議本身表示的,離序列化的輸出方向就越近。

下面還是上篇文章的簡單場景,前端以http調gateway,gateway以thrift rpc調service,來分別看一個在這個請求鏈路中,對gateway與service來說,分別經歷了幾次序列化。

gateway

在gateway中,比如一個flask應用,我總結序列化與反序列化通常有以下幾個過程。

  1. 由http請求而來的引數轉化為Python的dict,使用flask-restful+webargs(其使用了marshmallw),將gunicorn或是nginx過來的http協議內的資料反序列化為Python的dict結構;
  2. gateway要向service發thrift rpc請求,需要將dict結構反序列化為thrift生成的client程式碼中對應idl中定義的request物件,這裡可以抽出一個方法在抽象層面上做同樣結構的dict到相應request的序列化,呼叫方法可能為request = wrap_struct(user_info_dict, NewUserRequest)
  3. 在請求發出後,拿到rpc的response,得到的依然是由thrift生成的程式碼中由idl定義的response物件,此時可能需要一個序列化的方法將thrift的response物件序列化為dict結構,create_res = dump_struct(createResponse)
    當然,上面的兩步也可以使用marshmallow來做,但在gateway層再寫一堆schema用來做這個事情真的是有些冗餘了,一個更好的辦法是使用更加抽象的方式,在反序列化時給定一個dict與相應的已經由thrift生成的request類來生成相應object,同時,可以直接由thrift response生成相同層級結構的dict。
  4. gateway拿到資料後要給前端http的response,這是一層序列化,flask提供了jsonify將dict轉化為相應的結構返回,flask-restful更進了一步,直接在resource函式中返回json,它會自動做這層序列化;
    在工作中還見到過一些在這一層使用marshmallow,用來做什麼呢?設想,當你呼叫一個service取回一些資料,比如同樣是使用者的姓名這個欄位,在thrift介面中定義為name,呼叫其他團隊的服務,這個不依賴於你,而同時,之前跟前端定的介面中返回使用者名稱為username,相信各位有一定實踐經驗的同學都有這種欄位名轉換的經歷,在程式碼中手動處理這種轉換真的有些噁心,此時使用marshmallow的dump_to引數來做就會顯得比較優雅。
    若不是這種複雜的情況,直接使用dump_struct回來的資料直接返回給前端即可。

service

現在來說一下基礎服務層的各個序列化階段,與gateway還是有著明顯的不同的。
1- 拿到thrift過來的請求,使用上面提到的dump_struct將thrift response反序列化為dict,方便進行驗參等進一步操作;
2- 在資料庫插入記錄前,使用marshmallow的load()方法反序列化,檢查各個欄位是否符合規則,還見到過一種處理是直接在load()之後插入記錄,方法是使用@post_load裝飾器,在驗參成功後,直接在model中插入記錄,並返回給load的呼叫者,使用起來很自然;

from marshmallow import Schema, fields, validate, validates

class UserSchema(Schema):
    name = fields.Str(required=True, validate=lambda n: n)
    age = fields.Decimal(required=True, validate=lambda n: n > 18)
    location = fields.Str(required=True)
    
    @post_load
    def make_object(self, data):
        from model.user import User
        return User(**data)
複製程式碼

3- service調model執行查詢,對外要先吐出一層json格式的資料,見過一些不夠clean的方式是在model的class中定義to_json()方法(其實是返回dict物件),將model的各個欄位填入dict的key與value,大致長下面這個樣子:

def to_json(self):
    return {
        `name`: self.name,
        `age`: self.age,
        `location`: self.location
    }
複製程式碼

但這個轉換用marshmallow來處理明顯會更好一些,直接呼叫UserSchema().dump(record).data即可得到dict物件,這個UserSchema與request進來時load的時候是可以複用的,可以減少編寫上面那樣不怎麼樣的程式碼。同時,上面也提到過,可以使用dump_to來進行model與dict欄位的轉換,很好用;

4- service得到的dict物件返回給handler,handler使用wrap_struct(result, UserResponse)來進行反序列化,生成thrift response物件,最終給到thrift去處理。

網路通訊中的序列化

上面提到的都是往往都是需要開發自己去處理的,但在這個過程中,還有一些顯然存在的序列化過程,這裡簡單提一下。

uwsgi協議
正常情況下,python服務都不可能是裸奔的,它前面往往還有一層uwsgi(或gunicorn)與nginx。
uwsgi有它自己的二進位制協議,nginx配置後,將http請求序列化為uwsgi協議的傳輸二進位制給到uwsgi服務,uwsgi再將此二進位制反序列化為Python物件交給你的flask應用。

thrift中的序列化
上面有很多的地方都提到將request物件交給thrift,然後呢?
作為知名的開源rpc框架,它提供了多種序列化機制。支援xml,json等文字協議,亦可使用thrift或是google Protobuf協議,在可讀性與效能方面,使用者可以自由選擇。
拿到request的Python物件後,根據不同的序列化協議生成相應格式,最終還是要交給socket來進行資料傳輸處理。
再由socket拿出來後進行反序列化為thrift response。


上面提到的thrift中很重要的一點(思想)是,thrift的這些處理都是通過idl為基礎,使用程式碼生成器來生成的,開發人員只需要編寫idl檔案,就可以得到各種語言的直接可以使用的程式碼。

由上所述,各種轉化真的還蠻多的,難免會有各種重複的欄位定義等會出現在專案的各處(在http文件中,在idl中,在marshmallow schema中,在db model中),參考【程式設計師修煉之道】中的安利,再借鑑thrift的實踐構想,我認為在總結清楚這些常見的呼叫、序列化、驗參等規劃與比較好的具體實踐、程式碼編寫方式後,可以開發一個程式碼生成器,由一個類似idl的語言,來生成各階段的本來要由程式設計師去手動編寫的程式碼,從而大幅提高整體編碼效率與程式碼質量。
這肯定是可以實現的,因為thrift已經實現了它。

相關文章