架構小試之IDL

本文轉載自我自己的博客,感興趣的老爺們可以關注~://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 //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時,多一種思考的角度。

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

Tags: