我經歷的 Protocol Buffers 那些坑
文章內容
導語:Protocol Buffers是一種廣泛使用結構化資料儲存格式,可以用於結構化資料的序列化/反序列化,也是很多rpc框架的基礎之一,在Google內部大規模使用。本文作者根據自己多年研發經驗對PB的很多坑提出了自己的看法。
在我的職業生涯中,我花了很多時間來討論protobuffers的問題。PB顯然是由業餘選手寫的臨時作品,容易陷入困境且難於編譯,當然也解決了Google的問題。如果protobuffers的這些問題在序列化抽象中隔離出來,那麼我也不會繼續抱怨。 但不幸的是,protobuffers的糟糕設計是如此有感染力,以至於這些問題也會滲透到你的程式碼中。
由業餘愛好者建立和臨時性
我曾經在Google工作過。 Google是我第一次使用protobuffers地方(很遺憾不是最後一個)。 我今天要討論的所有問題都存在於Google的程式碼庫中; 這不是“錯誤使用protobuffers”的問題。
protobuffers的最大問題是其可怕的型別系統。 Java的粉絲應該感覺賓至如歸,但不幸的是,大家都不認為Java有一個設計良好的型別系統。使用動態語言的人抱怨這樣的型別系統太令人窒息了,而像我這樣的靜態語言粉絲會抱怨這種設計沒有給你真正想要的型別系統。
臨時性和業餘愛好者的建立是相輔相成的。大量protobuffer規範都是亡羊補牢的做法。 規範的許多限制會讓你禁不住發問,為何使用PB如此困難。然而這都只是表象,真正的原因是:
Protobuffers顯然是由業餘愛好者建造的,因為它們為廣為人知且已經解決的問題提供了不好的解決方案。
沒有組合性
Protobuffers提供了幾個“特性”,但多數無法相互配合。 例如,以下是幾個正交但受約束的特性列表。
oneof欄位不能repeated 。
map<k,v>欄位具有專用語法,不能用於任何其他型別。
儘管可以對map欄位進行引數化,但是不支援使用者定義的型別。 這意味著需要手動處理很多工作。
map欄位不能repeated 。
map鍵可以是string s,但不能是bytes 。 它們也不能enum ,即使enum在protobuffer規範中實際上是整數。
map值不能是其他map 。
這種瘋狂的限制列表是無原則設計和事後打補丁的結果。 例如,oneof欄位不能repeated是因為程式碼生成器不會產生副產品型別,而是為您提供互斥的可選欄位的產品。 這種轉換僅對單個欄位有效。
map欄位無法repeated也是相關的,但揭示了型別系統的不同限制。 原理上,map<k,v>應該類似於repeated Pair<k,v> 。 但是因為repeated是一個語言關鍵詞,而不是一個獨立的型別,它不能再次修飾自己,因此map欄位無法使用repeated。
估計你對為什麼enum不能用作map鍵,已經有了自己猜想。(譯者注:沒想到的或者想印證自己想法的,應該參加GIAC了)
令人沮喪的是,哪怕對現代型別系統如何工作有一點理解,就可以在大大簡化 protobuffer規範的同時消除很多限制。
解決方案如下:
使所有欄位都必須是required。這使得訊息都是產品型別。
將oneof欄位替換為獨立資料型別。這提供副產品型別。
提供通過其他型別引數化產品和副產品型別的能力。
只需要這三個功能,你就能定義任何可能的資料型別。 我們可以根據它們重新實現其餘的protobuffer規範。
例如,我們可以重建optional欄位:
構建repeated欄位也很簡單:
當然,實際允許的序列化邏輯比通過網路推送連結串列更高明 - 畢竟, 實現和語義不需要一對一對齊
有問題的選擇
在Java的基礎上,protobuffers區分了標量型別和訊息型別。 標量型別或多或少與機器原語相對應 - 比如int32 , bool和string 。 另一方面,其他型別都是訊息型別。 所有庫和使用者定義的型別都是訊息型別。
當然,這兩種型別的語義完全不同。
即使你沒有設定它們,標量型別的欄位也存在。 我提到過(至少在proto 3中 )所有protobuffers都可以零初始化。標量欄位獲取false-y值—例如, uint32初始化為0 , string初始化為"" 。將protobuffer中缺失的欄位與預設值欄位區分開來是不可能的。 這麼做是為了優化預設值(減少傳輸的資料)。
protobuffers聲稱可以向後向和向前相容,然而無法區分未設定值和預設值是一場噩夢。 如果確實是為了每個欄位節約一位(有或沒有)而做出如此設計,那麼有點不值當。
相比之下, 雖然標量型別設計的不夠好,但訊息型別欄位的行為就完全放飛自我了。訊息型別欄位無論是否存在,它們的行為都異常瘋狂。 其訪問程式碼值得細細剖析。 假設如下偽Java程式碼:
我們的想法是,如果未設定foo欄位,則無論何時請求都會看到預設初始化的副本,但實際上不會修改其容器。 但是如果修改foo ,它也會修改它的父級! 所有這一切只是為了避免使用Maybe Foo型別和相關的細微差別,需要弄清楚未設定值意味著什麼。
這種行為特別令人震驚,因為它破壞了規律! 我們期望賦值不會引起別的動作。 而PB將悄悄地更改msg以獲得foo的零初始化副本。
與標量欄位不同,我們至少可以檢測訊息欄位是否未設定。 protobuffers提供了生成的bool has_foo()方法。如果想複製foo,則需要編寫以下程式碼:
請注意,至少在靜態型別語言中,由於方法foo() , set_foo()和has_foo()之間的命名關係, 我們無法抽象處理。 除了前處理器巨集之外,我們無法以程式設計方式生成它們:
(但前處理器巨集是由Google code style指南禁止的。)
如果所有可選欄位都被實現為Maybe s,那麼將很容易抽象處理這種情況。
讓我們談談另一個有問題的決定。 雖然你可以在protobuffers中定義一個欄位,但它們的語義不是副產品型別! 相反,對於每種情況你得到一個可選欄位,以及setter中的魔術程式碼。如果設定了一個,它會將其他情況清除掉。
乍一看,這似乎應該在語義上等同於union型別。 但相反,它是bug之源! 這種行為允許默默地刪除任意數量的資料! 在protobuffers上編寫通用的,無錯誤的,多型的程式碼實際上是不可能的。
這不是任何人都喜歡聽到的東西,更不用說我們這些已經愛上引數多型性的人 - 這給了我們完全相反的承諾。
向後相容的謊言
protobuffers的另一個殺手特性是它們“編寫向後相容API的能力”。
protobuffers 預設情況下通過偷偷地執行錯誤操作來實現其相容性。 當然,謹慎的程式設計師可以(並且應該)會對接收到的protobuffers訊息進行檢查。 但是你需要不停編寫防禦性檢查程式碼以確保您的資料沒有問題,也許這只是意味著反序列化步驟過於寬鬆。 您所能做的就是將健全性檢查邏輯從定義良好的邊界中分散開來,並將擴散整個程式碼庫中。
另一個論點是,protobuffers將保留他們不理解的訊息中存在的任何資訊。 原則上,這意味著傳送路由訊息(不知道其schema版本)是非破壞性的。
當然,在紙面上它是一個很酷的功能。 但我從來沒有見過一個真正保留該屬性的應用程式。 除了路由軟體之外,沒有什麼其他軟體僅檢查訊息的某些位然後在未更改的情況下轉發訊息。 使用protobuffers的絕大多數程式將解碼訊息,將其轉換為別的訊息,並將其傳送給其他程式。 這些變換是需要手動編碼的。 從一個protobuffer到另一個protobuffer的手動編碼轉換不會保留兩者之間的未知欄位,因為它實際上毫無意義。
這種對待protobuffers的態度總是與其他醜陋的方式並舉。protobuffers的風格指南積極倡導反DRY,並建議儘可能內聯定義。 這背後的原因是,如果這些定義在將來發生分歧,它允許您單獨修改訊息。
這個問題的根源在於Google將資料的含義與其物理表示混為一談。 當你處於谷歌規模時,這種事情可能是有道理的。 畢竟,他們有一個內部工具,允許您比較程式設計師時間與網路利用率背後(或者其他事情)的成本。 與大多數公司不同,工程師薪水是谷歌最小的開支之一。 從財務角度來說,浪費程式設計師的時間以減少幾個位元組是有道理的。
在排名前五的科技公司之外,我們都跟Google的規模差了好幾個數量級。 你的創業公司不應該浪費工程師的時間來削減位元組數。 但是削減位元組並浪費程式設計師的時間正是protobuffers優化的原因。
面對現實吧。大部分公司永遠達不到Google的規模。 對那些只是因為“谷歌使用它”,因此“它是行業最佳實踐”的技術,我們應該停止搬到自己公司。
Protobuffers汙染程式碼庫
如果可以將protobuffer的使用限制在網路傳輸,我就不會如此為難。 不幸的是,雖然原則上有一些解決方案,但它們都不足以實際用於真實軟體。
Protobuffers對應於您希望傳送的資料,這通常與應用程式要使用的實際資料相關但不相同 。 這使我們處於一種令人不安的境地,需要在三種不良選擇中選擇一種:
維護一個描述您實際需要的資料的單獨型別,並確保兩者同步。
將資料打包成傳輸格式以供應用程式使用。
每次需要時都可以通過傳輸格式獲取資訊。
選項1顯然是“正確的”解決方案,但它與protobuffers無法匹配。 該語言的功能不夠強大,無法同時作為傳輸格式和應用程式資料格式。這意味著需要寫一個完全獨立的資料型別,與protobuffer同步,並在兩者之間顯式使用序列化程式碼來同步。而大部分人使用PB就是為了不寫序列化程式碼,所以這種情況不會發生。
相反,使用protobuffers的程式碼會在整個程式碼庫中擴散。 我在谷歌的參與的主要專案是一個編譯器,它用各種各樣的protobuffer作為輸入,並在另一個程式中輸出一個等價的“程式”。 輸入和輸出格式表達能力都足夠,然而保持適當並行的C++版本永遠不工作。程式碼無法利用我們為編寫編譯器而實現的任何豐富技術,因為protobuffer(以及由此產生的程式碼)過於僵化,無法做任何有趣的事情。
結果是,可能有50行遞迴程式碼就能實現的事情需要10,000行PB程式碼。 我想實現的功能因為PB的限制而無法實現。
雖然這是僅僅是一個例子,但它不是孤立的。 由於它們嚴格的程式碼生成,語言中的protobuffers的表現形式從來都不是慣用的方式。
但即使這樣,你仍然需要將一個糟糕的型別系統嵌入到目標語言中。 因為大多數protobuffers的功能都是不完善的,這些令人討厭的屬性也會洩漏到我們的程式碼庫中。 這意味著我們不僅要實現,而且還要在任何與之互動的專案中延續這些糟糕的想法。
在堅實的基礎上實現無用的功能很容易,但走向反面則是是挑戰。
簡而言之,放棄將protobuffers引入專案吧。
原文連結
https://mp.weixin.qq.com/s/DMzjJVaWGC4p45kII-dQ9w