摘要:不合理的選型在后續(xù)維護上會帶來不小的麻煩。因此一般公司會將所有服務的文件統(tǒng)一維護。生成的數(shù)據(jù)結構一般均支持序列化和反序列化,并且跨端跨語言。只支持,和數(shù)值三種結構,和支持相互嵌套,標準的的數(shù)值僅有這三種。只有大約的大小。
本文轉載自我自己的博客,感興趣的老爺們可以關注~:https://www.miaoerduo.com/2021/11/16/arch-idl/
為什么IDL的介紹也放在這里呢?一方面是我想不到放哪里,另一方面是之前說到,“架構”即“設計”,那么IDL、RPC框架也算是設計的一部分。不合理的選型在后續(xù)維護上會帶來不小的麻煩。
本文主要介紹我用過的一些IDL,并結合真實案例,分析他們的優(yōu)劣。
在我接手第一個項目的時候,就問了一個問題:這個idl文件夾是做什么的?
一年之后,當對新人介紹我們項目結構的時候,我都會忍不住試探的問句,你知道idl是什么意思嗎?發(fā)現(xiàn)大家和我一樣不了解,我才心滿意足的解釋一番。
IDL其實有很多的含義,在這里一般可以理解為接口描述語言(Interface description language),即描述服務的接口,類似我們C程序的接口聲明,包含:接口名和輸入輸出的數(shù)據(jù)結構。
一般每個服務均有自己的IDL文件(也可以是多個服務依賴相同的IDL文件,因為懶,或者其他巧妙的目的),比如我現(xiàn)在公司常用的服務是基于C++和Go的,使用Thrift作為IDL。
Thrift提供了工具,可以根據(jù)IDL編譯生成服務端和客戶端的代碼:
因此,一個接口的聲明,不僅指導當前服務的實現(xiàn),同時也是對上游服務的約定。因此一般公司會將所有服務的IDL文件統(tǒng)一維護。這樣只需要知道服務名和接口聲明,即可完成RPC服務的接入。
像Thrift這種IDL可以定義數(shù)據(jù)結構和接口,而有些IDL只可以定義數(shù)據(jù)結構。IDL生成的數(shù)據(jù)結構一般均支持序列化和反序列化,并且跨端、跨語言。這種本身不定義接口的IDL,也可以以string的方式搭配其他的RPC框架來使用(Thrift,gRPC等)。
這里我們主要介紹幾種典型的IDL:JSON、ProtoBuf、Thrift。當然IDL還有XML、FlatBuffer、BSON等,感興趣可以自行查閱。
JSON,JavaScript Object Notation,這個大家應該都了解,結構簡單,可讀性好,一般在Web開發(fā)中最常用到,是RESTFul API的首選。
JSON只支持Object,Array和數(shù)值三種結構,Object和Array支持相互嵌套,標準的JSON的數(shù)值僅有:double/boolean/string這三種。以下是個例子:
{ "name": "miao", "age": 18, "skill": [ { "name": "paint", "level": 1 }, { "name": "coding", "level": 2 } ]}
像C++的項目,一般直接使用RapidJSON這個庫,他的性能是十分優(yōu)秀的,并且支持拓展的數(shù)據(jù)類型。如果是純C的項目,可以考慮cJSON,我曾經(jīng)還提過MR????。
這里有個有意思的事情是,我之前編寫過一個工具,可以將程序的中間結果Dump成JSON格式用于Debug。但是有同事通過JSON的在線格式化工具查看的時候,數(shù)值看起來都被截斷了,數(shù)值的后幾位都是0。
最后發(fā)現(xiàn)是因為網(wǎng)頁版的工具只支持double,而RapidJSON可以準確的序列化出int64的數(shù)據(jù),int64到double的轉換導致了精度的丟失。鬧了個烏龍。
那么公司內(nèi)部服務間的通信使用JSON是一個好的選擇嗎?
我的觀點是,這不是一個好的選擇。(雖然現(xiàn)實是,我所在的公司經(jīng)常在服務間傳JSON)
有以下幾個原因:
首先,JSON沒有標準的Schema(RapidJSON提供了定義Schema的機制,但是校驗JSON的開銷也很大),比如我們在拿到數(shù)據(jù)之前,是不知道這個string中存在哪些數(shù)據(jù),也不能假定任意數(shù)據(jù)是存在的。這會造成我們在獲取任意的數(shù)據(jù)時,必須做各種判斷,設置兜底值。
JSON序列化的string一般也會很長,尤其數(shù)字的序列化,3.14159265359,這需要13個字節(jié)來存放。而實際上它是一個double,至多8個字節(jié)即可。
JSON的序列化和反序列化也相比其他IDL要慢了一些,比如上面的數(shù)字,理論上僅對二進制進行操作即可,而JSON必須轉成string。其次JSON序列化需要填充key和一些,[]{}
的字符。如果需要傳輸二進制數(shù)據(jù)的話,JSON一般會需要轉成Base64編碼,整體的編碼和體積又會進一步增大。
最后是解析很復雜,由于沒有Schema,導致每個字段都需要做解析和判斷。另外很多JSON的解析庫,對于Object和Array,底層使用鏈表來實現(xiàn)的,查詢效率是線性的。
Protocol Buffers,簡稱PB,是一種數(shù)據(jù)描述的工具,它可以定義豐富的數(shù)據(jù)結構,支持基礎數(shù)據(jù)類型(int, float, string等)、常用容器list和map,以及自定義的組合數(shù)據(jù)類型(Message)。
PB有2和3兩個版本,二者并不兼容,以下是PB2的Schema的定義:
syntax = "proto2";package med; // 包名,相對于C++的namespacemessage 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_pb2import json# raw datauser = { name: miao, age:18, skill: [ { name: paint, level: 1 }, { name: coding, level: 2 }, ]}# convert to pbpb_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 compactjson_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: 89Content:{"name":"miao","age":18,"skill":[{"name":"paint","level":1},{"name":"coding","level":2}]}============ PB ============Size: 31Context:b/n/x04miao/x10/x12/x1a/t/n/x05paint/x10/x01/x1a/n/n/x06coding/x10/x02
可以看出,首先PB是有Schema的,任何人只要拿到Schema,就可以容易的解析PB數(shù)據(jù)。
PB序列化出的數(shù)據(jù)比JSON小了很多。只有大約1/3的大小。(這里主要是節(jié)省了JSON的Key的部分)。同時一般情況下,PB的序列化和反序列化的速度比JSON更快(有沒有PB更慢的情況呢?后續(xù)案例會提到)。
在讀取值的情況下,JSON需要根據(jù)key去查找具體的數(shù)據(jù),而PB的每個成員定義最終都是一個函數(shù)(C++中是函數(shù),Python更像是成員變量),可以用調用函數(shù)的方式去取值,節(jié)省了一次查找的開銷,因此讀取的速度極高。
另外PB支持反射,既可以輸入一個string,可以通過反射的方式獲取到他的值,但是PB反射的用法比較復雜,這個可以多帶帶寫篇博客來介紹。
關于PB,其實也有許多坑的地方。比如PB2和PB3不兼容,PB3沒有optional字段,PB的庫版本不匹配容易出錯等。所以我們盡量把PB2和3看成兩個工具,一開始就決定好使用哪個。
與PB十分相似的有個IDL是FlatBuffer,他和PB支持的數(shù)據(jù)類型基本一致,但在構建對象的時候,保證了數(shù)據(jù)是原始數(shù)據(jù)且內(nèi)存分布和IDL定義一致。帶來的好處是,F(xiàn)latBuffer序列化的字符串,可以直接讀取,而不需要反序列的操作,因此解碼時間可以理解為0,在游戲行業(yè)應用較多。
Thrift和上面兩個存在本質的不同。
Thrift不僅可以定義數(shù)據(jù)結構,這一點和PB相同,同時還可以定義RPC的接口。使用相關的工具,可以方便的生成RPC的Server和Client的代碼。
struct Skill { 1: string name, 2: i32 level,}struct User { 1: string name, 2: i32 age, 3: list 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 TSocketfrom thrift.transport import TTransportfrom thrift.protocol import TBinaryProtocolfrom thrift.server import TServerimport syssys.path.append("./gen-py/")from demo import EstimateServerclass 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 rspif __name__ == __main__: # 創(chuàng)建處理器 handler = EstimateHandler() processor = EstimateServer.Processor(handler) # 監(jiān)聽端口 transport = TSocket.TServerSocket(host="0.0.0.0", port=9999) # 選擇傳輸層 tfactory = TTransport.TBufferedTransportFactory() # 選擇傳輸協(xié)議 pfactory = TBinaryProtocol.TBinaryProtocolFactory() # 創(chuàng)建服務端 server = TServer.TThreadPoolServer(processor, transport, tfactory, pfactory) # 設置連接線程池數(shù)量 server.setNumThreads(5) # 啟動服務 server.serve()
from thrift import Thriftfrom thrift.transport import TSocketfrom thrift.transport import TTransportfrom thrift.protocol import TBinaryProtocolimport syssys.path.append("./gen-py/")from demo import EstimateServerif __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: 10086hi~ miao, Your Ability: skill: paint level: 1 skill: coding level: 2"""
Thrift的序列化有點復雜,感興趣的可以查看client.estimate
的源代碼,我們大致可以知道,Thrift的序列化的體積和PB應該類似。
Thrift和PB支持的數(shù)據(jù)類型基本上一致,但是同時支持了RPC接口的定義。但是比較遺憾的是Thrift不支持反射。當字段太多的時候,想支持參數(shù)解析的配置化,就比較麻煩。
首先給出上面三種IDL的各類情況:
IDL | 編解碼 | 體積 | 反射 | RPC接口 | Schema | 可讀性 |
---|---|---|---|---|---|---|
PB | 快 | 小 | 支持 | 不支持 | 支持 | 需解碼 |
Thrift | 快 | 小 | 不支持 | 支持 | 支持 | 需解碼 |
JSON | 慢 | 大 | 支持 | 不支持 | - | 好 |
由于這里Thrift是用來定義服務的,因此一定會被用到,這里主要討論的是一次RPC調用時,內(nèi)部的具體數(shù)據(jù)的選擇。
以下我們分場景討論。
AB參指是我們通過實驗平臺下發(fā)實驗的參數(shù)。一般我們在開發(fā)完一個功能之后,并不一定會立刻上線推全,而是在線上保留新舊兩套邏輯,再通過平臺下發(fā)參數(shù)來控制分別啟用新舊邏輯。用于做對比實驗。
一般AB參會隨著請求下發(fā)到每個服務。如果AB實驗得到了具體的結論,就可以固化AB參(刪掉舊代碼,或者全量新的AB參)。
那么一個合格的AB參選型需要滿足:
先說結論,這里優(yōu)先考慮JSON和PB,PB依賴一些額外的工作。單純使用Thrift不可行。
這里排除直接使用PB和Thrift的Map結構的情況,因為這樣和JSON幾乎等價,表達能力卻不如JSON。
首先,JSON是很適合的選擇。它的構造很簡單,組織靈活,如果數(shù)據(jù)量不大的話,解析速度也還可以。同時由于支持反射,一些邏輯的配置化也比較方便的實現(xiàn)。并且基本上所有的語言都可以很好的支持。原生支持數(shù)據(jù)透傳,不依賴上下游的服務升級。
缺點是當數(shù)據(jù)量比較大的時候,JSON會占用很大一部分服務的CPU和帶寬。
那么PB和Thrift有什么問題呢?核心是數(shù)據(jù)傳遞的完整性。另外Thrift不支持反射也是個硬傷。
假設服務調用是A->B->C,C是最下游的服務,我們的代碼寫在C中。新增AB參時,我們在IDL中增加一個字段。在開發(fā)上線完C后,A、B可能也需要同步升級以支持透傳參數(shù)。不然在開實驗時,A、B無法將數(shù)據(jù)透傳到下游,影響實驗的發(fā)布。Thrift的參數(shù)直接體現(xiàn)在RPC接口中,更新字段必須重新上線,因此這里Thrift就不太適合。
而PB本身可以序列化成String放在請求里面,因此如果是透傳全量的AB參,這是可以保證的。
另一種情況是,B這個服務對AB參做了拆分,然后僅透傳其中的一部分給C。那么如果B的IDL是舊版的,那么還能完成透傳嗎?這里其實PB是有相關的支持的。
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 wont 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及以后會透傳。
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參的更嚴格的監(jiān)管,其實也是好事,可以為整個服務鏈路做更好的監(jiān)控,這取決于公司是否愿意投入人力去解決。
我們經(jīng)常聽到倒排索引這個概念,其實正排更常見。比如存放用戶的信息,一般就是一個map,key是user_id,val是用戶的具體信息。
提到KV存儲,我們很容易想到Redis,Memcached,LMDB等工具,具體的選擇以后再討論。一般正排是獨立的一個服務,對于正排的查詢就會是一次RPC請求。因此,正排中的val一般是序列化好的字符串,以減少再次序列化的開銷。
這里就是PB的極好的應用場景。
對于一個正排服務,一般會將數(shù)據(jù)分shard然后放進內(nèi)存,RPC是直接讀取了內(nèi)存的數(shù)據(jù)。這種服務一般瓶頸容易出現(xiàn)在內(nèi)存和帶寬上,壓縮率越高,就意味著更少的資源。PB擁有極高的壓縮率,序列化和反序列化均很快,又支持反射。
另外,如果一個val存放了過多的字段,而我們只想獲取少部分字段時,由于服務端不方便做解碼,我們必須一次請求所有的數(shù)據(jù),這樣就會帶來帶寬上的浪費。一般的解決方案是將正排的val做拆分。大val時,數(shù)據(jù)庫的選型也是個問題,比如Redis對大的val支持并不好。這個我們后續(xù)會再介紹。
這是指一個數(shù)據(jù)的定義有1000個字段,但是一條記錄可能只會填充其中的幾十個字段的情況。
常見于埋點數(shù)據(jù),還有上面AB參(隨著時間推移,很多無用的AB參未及時清理)。
這種情況下,PB和JSON哪個更好的?我們沒有一個比較明確的答案。
這里碰到了一個案例,有同事將埋點數(shù)據(jù)從JSON改成了PB,然后重構了整條鏈路之后,發(fā)現(xiàn)優(yōu)化前后CPU和內(nèi)存均持平。
推測原因是,一條JSON只保存了幾十個字段的KV,而PB保存了所有字段的狀態(tài)和數(shù)據(jù)(PB2會記錄每個字段是否被set),因此存儲上PB有浪費。解析也同理。
上述的案例的答案可能并不適用于其他場景,僅供大家了解。這里的目的是,希望在大家選擇IDL時,多一種思考的角度。
本文寫了真的好久,總算是寫完啦~
文章版權歸作者所有,未經(jīng)允許請勿轉載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/123646.html
摘要:最近呢,在做的設計對于設計,一方面是對于后端框架的設計,另一方面呢,是對于整個體系的設計在這里呢,我們來理理思路,先來大致分一下塊風格就不用說了,我們就用風格,接下來,也就是我們所說的接口描述語言框架,整個服務的核心驅動版本控制還有一些輔助 最近呢,在做 api 的設計 對于設計,一方面是對于后端 server 框架的設計,另一方面呢,是對于整個 api 體系的設計 在這里呢,我們來理...
摘要:插畫牛肉框架小怪獸的電報員一旦系統(tǒng)怪物被拆分成了多個服務小怪獸,小怪獸們?nèi)绾螠贤▍f(xié)作就成了我們最關心的問題。插畫牛肉實現(xiàn)客戶端小怪獸發(fā)送今晚的月色真美,服務端小怪獸收到電報內(nèi)容,并回復。 作者:亞瑟、文遠 1. 微服務框架 -- 從系統(tǒng)怪物到服務小怪獸 一個小巧的單體應用會隨著公司業(yè)務的擴張而慢慢成長,逐漸演化成一個龐大且復雜的系統(tǒng)怪物,系統(tǒng)任何一處的問題都將影響整個怪物的表現(xiàn),很少有...
摘要:服務器端使用它來做頂層接口,編寫實現(xiàn)類。會自動生成同步調用和異步調用的兩個接口。方法參數(shù)的封裝類,以方法名命名方法返回值的封裝類,以方法名命名參考個人博客 基本概念 輕量級、跨語言的RPC框架 功能特點: 基于IDL(接口描述語言)生成跨語言的RPC clients and servers,支持超過20種語言 支持二進制的高性能的編解碼框架 支持NIO的底層通信 相對簡單的服務調用模...
摘要:上一篇,講到了,最近,在做的設計對于設計,一方面是對于后端框架的設計,另一方面呢,是對于整個體系的設計在這里呢,我們來理理思路,先來大致分一下塊風格就不用說了,我們就用風格,接下來,也就是我們所說的接口描述語言框架,整個服務的核心驅動版本控 上一篇,講到了,最近,在做 api 的設計 對于設計,一方面是對于后端 server 框架的設計,另一方面呢,是對于整個 api 體系的設計 在這...
閱讀 713·2023-04-25 19:43
閱讀 3910·2021-11-30 14:52
閱讀 3784·2021-11-30 14:52
閱讀 3852·2021-11-29 11:00
閱讀 3783·2021-11-29 11:00
閱讀 3869·2021-11-29 11:00
閱讀 3557·2021-11-29 11:00
閱讀 6105·2021-11-29 11:00