架構小試之IDL

喵耳朵發表於2021-11-17

本文轉載自我自己的部落格,感興趣的老爺們可以關注~:https://www.miaoerduo.com/2021/11/16/arch-idl/

為什麼IDL的介紹也放在這裡呢?一方面是我想不到放哪裡,另一方面是之前說到,“架構”即“設計”,那麼IDL、RPC框架也算是設計的一部分。不合理的選型在後續維護上會帶來不小的麻煩。

本文主要介紹我用過的一些IDL,並結合真實案例,分析他們的優劣。

IDL的作用

在我接手第一個專案的時候,就問了一個問題:這個idl資料夾是做什麼的?

一年之後,當對新人介紹我們專案結構的時候,我都會忍不住試探的問句,你知道idl是什麼意思嗎?發現大家和我一樣不瞭解,我才心滿意足的解釋一番。

IDL其實有很多的含義,在這裡一般可以理解為介面描述語言(Interface description language),即描述服務的介面,類似我們C程式的介面宣告,包含:介面名和輸入輸出的資料結構

一般每個服務均有自己的IDL檔案(也可以是多個服務依賴相同的IDL檔案,因為懶,或者其他巧妙的目的),比如我現在公司常用的服務是基於C++和Go的,使用Thrift作為IDL。

Thrift提供了工具,可以根據IDL編譯生成服務端和客戶端的程式碼:

  • 對於服務端而言,我們只需要繼承生成的Server類,然後實現具體的介面的內容即可。
  • 客戶端(即呼叫方),IDL可以生成Client類,方便的進行呼叫。

因此,一個介面的宣告,不僅指導當前服務的實現,同時也是對上游服務的約定。因此一般公司會將所有服務的IDL檔案統一維護。這樣只需要知道服務名和介面宣告,即可完成RPC服務的接入。

像Thrift這種IDL可以定義資料結構和介面,而有些IDL只可以定義資料結構。IDL生成的資料結構一般均支援序列化和反序列化,並且跨端、跨語言。這種本身不定義介面的IDL,也可以以string的方式搭配其他的RPC框架來使用(Thrift,gRPC等)。

這裡我們主要介紹幾種典型的IDL:JSON、ProtoBuf、Thrift。當然IDL還有XML、FlatBuffer、BSON等,感興趣可以自行查閱。

幾類常見的IDL

JSON

JSON,JavaScript Object Notation,這個大家應該都瞭解,結構簡單,可讀性好,一般在Web開發中最常用到,是RESTFul API的首選。

JSON只支援Object,Array和數值三種結構,Object和Array支援相互巢狀,標準的JSON的數值僅有:double/boolean/string這三種。以下是個例子:

{
    "name": "miao",
    "age": 18,
    "skill": [
        {
            "name": "paint",
            "level": 1
        },
        {
            "name": "coding",
            "level": 2
        }
    ]
}

像C++的專案,一般直接使用RapidJSON這個庫,他的效能是十分優秀的,並且支援擴充的資料型別。如果是純C的專案,可以考慮cJSON,我曾經還提過MR?。

這裡有個有意思的事情是,我之前編寫過一個工具,可以將程式的中間結果Dump成JSON格式用於Debug。但是有同事通過JSON的線上格式化工具檢視的時候,數值看起來都被截斷了,數值的後幾位都是0。
最後發現是因為網頁版的工具只支援double,而RapidJSON可以準確的序列化出int64的資料,int64到double的轉換導致了精度的丟失。鬧了個烏龍。

那麼公司內部服務間的通訊使用JSON是一個好的選擇嗎?

我的觀點是,這不是一個好的選擇。(雖然現實是,我所在的公司經常在服務間傳JSON)

有以下幾個原因:

  1. 沒有Schema
  2. 頻寬佔用大
  3. 序列化和反序列化的時間開銷
  4. 解析複雜

首先,JSON沒有標準的Schema(RapidJSON提供了定義Schema的機制,但是校驗JSON的開銷也很大),比如我們在拿到資料之前,是不知道這個string中存在哪些資料,也不能假定任意資料是存在的。這會造成我們在獲取任意的資料時,必須做各種判斷,設定兜底值。

JSON序列化的string一般也會很長,尤其數字的序列化,3.14159265359,這需要13個位元組來存放。而實際上它是一個double,至多8個位元組即可。

JSON的序列化和反序列化也相比其他IDL要慢了一些,比如上面的數字,理論上僅對二進位制進行操作即可,而JSON必須轉成string。其次JSON序列化需要填充key和一些,[]{}的字元。如果需要傳輸二進位制資料的話,JSON一般會需要轉成Base64編碼,整體的編碼和體積又會進一步增大。

最後是解析很複雜,由於沒有Schema,導致每個欄位都需要做解析和判斷。另外很多JSON的解析庫,對於Object和Array,底層使用連結串列來實現的,查詢效率是線性的。

Protobuf

Protocol Buffers,簡稱PB,是一種資料描述的工具,它可以定義豐富的資料結構,支援基礎資料型別(int, float, string等)、常用容器list和map,以及自定義的組合資料型別(Message)。

PB有2和3兩個版本,二者並不相容,以下是PB2的Schema的定義:

syntax = "proto2";

package med;                  // 包名,相對於C++的namespace

message Skill {
  required string name = 1;
  required int32 level = 2;
}

message User {
  required string name = 1;   // required表示該欄位必須要有
  optional int32 age = 2;     // optional表示該欄位可選
  repeated Skill skill = 3;   // 多個Skill結構
}

通過protoc user.proto —python_out=. 編譯生成了user_pb2.py檔案。

我們簡單使用一下這個IDL,這裡使用的Proto2生成的:

"""
pip3 install -i https://pypi.douban.com/simple/ protobuf
"""

import user_pb2
import json

# raw data
user = {
    'name': 'miao',
    'age':18,
    'skill': [
        {
            'name': 'paint',
            'level': 1
        },
        {
            'name': 'coding',
            'level': 2
        },
    ]
}

# convert to pb
pb_user = user_pb2.User()
pb_user.name = user['name']
pb_user.age = user['age']

for skill in user['skill']:
    pb_skill = user_pb2.Skill()
    pb_skill.name = skill['name']
    pb_skill.level = skill['level']
    pb_user.skill.append(pb_skill)

# convert to JSON
#  the given separators will make it compact
json_user = json.dumps(user, separators=(',', ':'))

print("============ JSON ============")

print("Size: {}\nContent:\n\t{}".format(len(json_user), json_user))

print("============  PB  ============")
print('Size: {}\nContext:\n\t{}'.format(pb_user.ByteSize(), pb_user.SerializeToString()))

'''
OUTPUT:
============ JSON ============
Size: 89
Content:
{"name":"miao","age":18,"skill":[{"name":"paint","level":1},{"name":"coding","level":2}]}
============  PB  ============
Size: 31
Context:
b'\n\x04miao\x10\x12\x1a\t\n\x05paint\x10\x01\x1a\n\n\x06coding\x10\x02'
'''

可以看出,首先PB是有Schema的,任何人只要拿到Schema,就可以容易的解析PB資料。

PB序列化出的資料比JSON小了很多。只有大約1/3的大小。(這裡主要是節省了JSON的Key的部分)。同時一般情況下,PB的序列化和反序列化的速度比JSON更快(有沒有PB更慢的情況呢?後續案例會提到)。

在讀取值的情況下,JSON需要根據key去查詢具體的資料,而PB的每個成員定義最終都是一個函式(C++中是函式,Python更像是成員變數),可以用呼叫函式的方式去取值,節省了一次查詢的開銷,因此讀取的速度極高。

另外PB支援反射,既可以輸入一個string,可以通過反射的方式獲取到他的值,但是PB反射的用法比較複雜,這個可以單獨寫篇部落格來介紹。

關於PB,其實也有許多坑的地方。比如PB2和PB3不相容,PB3沒有optional欄位,PB的庫版本不匹配容易出錯等。所以我們儘量把PB2和3看成兩個工具,一開始就決定好使用哪個。

與PB十分相似的有個IDL是FlatBuffer,他和PB支援的資料型別基本一致,但在構建物件的時候,保證了資料是原始資料且記憶體分佈和IDL定義一致。帶來的好處是,FlatBuffer序列化的字串,可以直接讀取,而不需要反序列的操作,因此解碼時間可以理解為0,在遊戲行業應用較多。

Thrift

Thrift和上面兩個存在本質的不同。

Thrift不僅可以定義資料結構,這一點和PB相同,同時還可以定義RPC的介面。使用相關的工具,可以方便的生成RPC的Server和Client的程式碼。

struct Skill {
    1: string name,
    2: i32 level,
}

struct User {
    1: string name,
    2: i32 age,
    3: list<Skill> skill,
}

struct Req {
    1: string log_id,
    2: User user,
}

struct Rsp {
    1: string log_id,
    2: string data,
}

service EstimateServer {
    Rsp estimate(1: Req),
}

thrift --gen py demo.thrift 命令可以生成對應的python程式碼,這裡預設在gen-py資料夾。

from thrift.transport import TSocket
from thrift.transport import TTransport
from thrift.protocol import TBinaryProtocol
from thrift.server import TServer
import sys

sys.path.append("./gen-py/")
from demo import EstimateServer

class EstimateHandler:
    def __init__(self):
        pass

    def estimate(self, req):
        user = req.user
        rsp = EstimateServer.Rsp(log_id=req.log_id)
        msg = 'hi~ {}, Your Ability: \r\n'.format(user.name)
        for skill in user.skill:
            msg += '    skill: {} level: {}\r\n'.format(skill.name, skill.level)
        rsp.data = msg
        return rsp

if __name__ == '__main__':
    # 建立處理器
    handler = EstimateHandler()
    processor = EstimateServer.Processor(handler)

    # 監聽埠
    transport = TSocket.TServerSocket(host="0.0.0.0", port=9999)

    # 選擇傳輸層
    tfactory = TTransport.TBufferedTransportFactory()

    # 選擇傳輸協議
    pfactory = TBinaryProtocol.TBinaryProtocolFactory()

    # 建立服務端
    server = TServer.TThreadPoolServer(processor, transport, tfactory, pfactory)

    # 設定連線執行緒池數量
    server.setNumThreads(5)

    # 啟動服務
    server.serve()
from thrift import Thrift
from thrift.transport import TSocket
from thrift.transport import TTransport
from thrift.protocol import TBinaryProtocol
import sys
sys.path.append("./gen-py/")

from demo import EstimateServer

if __name__ == '__main__':
    transport = TSocket.TSocket('127.0.0.1', 9999)
    transport = TTransport.TBufferedTransport(transport)
    protocol = TBinaryProtocol.TBinaryProtocol(transport)
    client = EstimateServer.Client(protocol)

    user = EstimateServer.User(name='miao', age=18)
    user.skill = [
        EstimateServer.Skill(name='paint', level=1),
        EstimateServer.Skill(name='coding', level=2)
    ]

    # 連線服務端
    transport.open()

    rsp = client.estimate(EstimateServer.Req(log_id="10086", user=user))
    print('log_id: {}'.format(rsp.log_id))
    print(rsp.data)

    # 斷連服務端
    transport.close()

"""
log_id: 10086
hi~ miao, Your Ability: 
    skill: paint level: 1
    skill: coding level: 2
"""

Thrift的序列化有點複雜,感興趣的可以檢視client.estimate的原始碼,我們大致可以知道,Thrift的序列化的體積和PB應該類似。

Thrift和PB支援的資料型別基本上一致,但是同時支援了RPC介面的定義。但是比較遺憾的是Thrift不支援反射。當欄位太多的時候,想支援引數解析的配置化,就比較麻煩。

IDL之間的對比和選擇

首先給出上面三種IDL的各類情況:

IDL 編解碼 體積 反射 RPC介面 Schema 可讀性
PB 支援 不支援 支援 需解碼
Thrift 不支援 支援 支援 需解碼
JSON 支援 不支援 -

由於這裡Thrift是用來定義服務的,因此一定會被用到,這裡主要討論的是一次RPC呼叫時,內部的具體資料的選擇。

以下我們分場景討論。

AB參

AB參指是我們通過實驗平臺下發實驗的引數。一般我們在開發完一個功能之後,並不一定會立刻上線推全,而是線上上保留新舊兩套邏輯,再通過平臺下發引數來控制分別啟用新舊邏輯。用於做對比實驗。

一般AB參會隨著請求下發到每個服務。如果AB實驗得到了具體的結論,就可以固化AB參(刪掉舊程式碼,或者全量新的AB參)。

那麼一個合格的AB參選型需要滿足:

  1. 易於構造
  2. 體積小
  3. 組織靈活
  4. 解析速度快
  5. Schema簡單

先說結論,這裡優先考慮JSON和PB,PB依賴一些額外的工作。單純使用Thrift不可行。

這裡排除直接使用PB和Thrift的Map結構的情況,因為這樣和JSON幾乎等價,表達能力卻不如JSON。

首先,JSON是很適合的選擇。它的構造很簡單,組織靈活,如果資料量不大的話,解析速度也還可以。同時由於支援反射,一些邏輯的配置化也比較方便的實現。並且基本上所有的語言都可以很好的支援。原生支援資料透傳,不依賴上下游的服務升級。

缺點是當資料量比較大的時候,JSON會佔用很大一部分服務的CPU和頻寬。

那麼PB和Thrift有什麼問題呢?核心是資料傳遞的完整性。另外Thrift不支援反射也是個硬傷。

假設服務呼叫是A->B->C,C是最下游的服務,我們的程式碼寫在C中。新增AB參時,我們在IDL中增加一個欄位。在開發上線完C後,A、B可能也需要同步升級以支援透傳引數。不然在開實驗時,A、B無法將資料透傳到下游,影響實驗的釋出。Thrift的引數直接體現在RPC介面中,更新欄位必須重新上線,因此這裡Thrift就不太適合。

而PB本身可以序列化成String放在請求裡面,因此如果是透傳全量的AB參,這是可以保證的。

另一種情況是,B這個服務對AB參做了拆分,然後僅透傳其中的一部分給C。那麼如果B的IDL是舊版的,那麼還能完成透傳嗎?這裡其實PB是有相關的支援的。

PB2直接支援低版本透傳高版本的欄位。

PB2

Any new fields that you add should be optional or repeated. This means that any messages serialized by code using your "old" message format can be parsed by your new generated code, as they won't be missing any required elements. You should set up sensible default values for these elements so that new code can properly interact with messages generated by old code. Similarly, messages created by your new code can be parsed by your old code: old binaries simply ignore the new field when parsing. However, the unknown fields are not discarded, and if the message is later serialized, the unknown fields are serialized along with it – so if the message is passed on to new code, the new fields are still available.

PB3,在3.5之前會丟棄新欄位,3.5及以後會透傳。

PB3

Originally, proto3 messages always discarded unknown fields during parsing, but in version 3.5 we reintroduced the preservation of unknown fields to match the proto2 behavior. In versions 3.5 and later, unknown fields are retained during parsing and included in the serialized output.

當然這個特性是PB所支援的,如果使用其他的IDL,也需要提前調研一下。

其實還有個問題是實驗平臺的支援。

一般公司會都有個實驗平臺,在上面我們通過視覺化的方式即可進行實驗的配置。使用PB的話,意味著新增AB參時,都需要在平臺進行註冊,否則平臺不認識,無法正確寫入欄位。當然對AB參的更嚴格的監管,其實也是好事,可以為整個服務鏈路做更好的監控,這取決於公司是否願意投入人力去解決。

正排

我們經常聽到倒排索引這個概念,其實正排更常見。比如存放使用者的資訊,一般就是一個map,key是user_id,val是使用者的具體資訊。

提到KV儲存,我們很容易想到Redis,Memcached,LMDB等工具,具體的選擇以後再討論。一般正排是獨立的一個服務,對於正排的查詢就會是一次RPC請求。因此,正排中的val一般是序列化好的字串,以減少再次序列化的開銷。

這裡就是PB的極好的應用場景。

對於一個正排服務,一般會將資料分shard然後放進記憶體,RPC是直接讀取了記憶體的資料。這種服務一般瓶頸容易出現在記憶體和頻寬上,壓縮率越高,就意味著更少的資源。PB擁有極高的壓縮率,序列化和反序列化均很快,又支援反射。

另外,如果一個val存放了過多的欄位,而我們只想獲取少部分欄位時,由於服務端不方便做解碼,我們必須一次請求所有的資料,這樣就會帶來頻寬上的浪費。一般的解決方案是將正排的val做拆分。大val時,資料庫的選型也是個問題,比如Redis對大的val支援並不好。這個我們後續會再介紹。

稀疏欄位的資料

這是指一個資料的定義有1000個欄位,但是一條記錄可能只會填充其中的幾十個欄位的情況。

常見於埋點資料,還有上面AB參(隨著時間推移,很多無用的AB參未及時清理)。

這種情況下,PB和JSON哪個更好的?我們沒有一個比較明確的答案。

這裡碰到了一個案例,有同事將埋點資料從JSON改成了PB,然後重構了整條鏈路之後,發現優化前後CPU和記憶體均持平。

推測原因是,一條JSON只儲存了幾十個欄位的KV,而PB儲存了所有欄位的狀態和資料(PB2會記錄每個欄位是否被set),因此儲存上PB有浪費。解析也同理。

寫在最後

上述的案例的答案可能並不適用於其他場景,僅供大家瞭解。這裡的目的是,希望在大家選擇IDL時,多一種思考的角度。

本文寫了真的好久,總算是寫完啦~

相關文章