上游優先的故事

語言: CN / TW / HK

作者:tison,Apache Member & 孵化器導師,StreamNative Community Manager

微信公眾號夜天之書”,關注開源共同體的發展,致力於回答 “如何建設一個開源社群” 的問題

原文連結:http://mp.weixin.qq.com/s/GL2_wMxdYBpM399RmNJQKg

開源軟體的使用者在使用過程中遇到問題時,幾乎總是先在自己的環境上打補丁繞過或快速修復問題。開源協同的語境下,開源軟體以及維護開源軟體的社群統稱為該軟體的上游,使用者依賴上游軟體的應用或基於上游軟體復刻(fork)的版本統稱為下游。上游優先(Upstream First),指的就是使用者將下游發現的問題、做出的修改反饋到上游社群的策略。

網路上已經有不少文章討論上游優先的定義、意義和通用的做法。例如,小馬哥為極狐 GitLab 撰寫了《Upstream First: 參與貢獻開源專案的正確方式》。不過,這些文章往往是站在社群、平臺或佈道師的層面做籠統的介紹。本文希望從一個開發者的角度出發,由幾個具體的上游優先的故事,討論開發者角度實踐上游優先策略的動機和方法。

故事和經歷

01 Spotless

第一個要講的是我在 Spotless 社群的參與經歷。Spotless 是一個主要關注在 JVM 系語言的程式碼自動格式化軟體,被 Apache Flink 等專案廣泛採用。

我第一次給 Spotless 提交補丁是因為在一個個人專案當中同時使用 Java 17 和 Spotless 外掛,遇到了 Java 17 嚴格約束 JDK 介面導致的 Spotless 外掛啟用 google-java-format 規則時不工作的情況。

起初,我按照 issue-834 上的繞過方法解決了問題。但是一方面繞過方法不太舒服,需要使用者感知和主動修改;另一方面,這個解法不向後相容:如果一個專案想要同時支援 Java 8 和 Java 17 編譯,該繞過方法會導致 Java 8 編譯失敗。

我遇到這個問題的時候,這個問題已經發生一年了。而且這種 Java 上游改內部介面導致下游爆炸的情況,一般很容易導致其他專案出問題。於是我嘗試搜尋相關問題,幸運地發現了 Kotlin 提供瞭解決問題根源的方法:

依樣畫葫蘆就在 Spotless 上游把問題解了。

聽起來簡單,其實搜尋到的程式碼片段只是提示了核心解法,減少了思考可行方案的時間。要把相關程式碼移植到另一個專案裡,並且理解原專案的構建方式打包後測試,以及按照專案的慣例完成文件更新等等工作,最終讓上游接受合併請求,就需要積極和上游維護者溝通,也需要發現和理解上游社群執行規則的方法和耐心。

好在 Spotless 的維護者 Ned Twigg 相當外向,很快進行程式碼評審並解釋了程式碼以外需要做的工作,一週以內就完成了補丁合併。通過自動化釋出的流程,補丁合併以後新版本就會自動釋出,下游幾乎能在合併當天就更新版本用上自己提交的補丁。

這裡有個小插曲。雖然這個修復乍一看也可以在 google-java-format 上游做,但是我經歷了 GCP 相關的一個 pull request 被掛數月的折磨以後,對 Google 專案實在沒什麼期待,所以選擇在 Spotless 解決問題。事後發現這個解法是正確的,因為這樣解能搞定 Spotless + google-java-format 1.7 的組合,而在 google-java-format 修大概率是不會為早期版本 pick 的。此外,Spotless 上的修復是模組化的,起初只是在 google-java-format 的路徑上啟用,隨後發現了其他格式化規則也有類似的問題,只要簡單地把修復模組在其他規則上啟用就可以了。

一週以內上游合併釋出以後,我在下游使用新版本的 Spotless 解決了之前的問題。然後,趁著熱乎勁,我把之前搜尋解法的時候搜到的其他有同樣問題的專案,看著順眼的就把我的修復版本提交給他們。今天我再去看原來的 issue 和提交的補丁,能夠看到一系列下游專案引用我的工作修復他們的問題,我很開心。

然後,就在國慶假期前給 Flink 更新 Spotless 版本解決這個問題的時候,我又發現了另外一個問題,原先可以用過 skip 引數跳過某些 Maven 模組 Spotless 檢查的功能在升級以後不管用了。

我的第一反應就是到上游搜尋相似問題,發現 issue-1227 也報告了這個問題。由於升級前的版本是好的,升級後的版本出了問題,加上我能定位到好的版本里 skip 引數起作用的相關程式碼是哪些,很快我就二分找到了引入問題的提交,並且在一個小時之內提交了修復。

這次輕車熟路,所有該辦的我能辦的事情,我都一次性搞完了。Ned Twigg 的反應還是很迅速,半小時內就反饋了 review 意見,我再求助其他開發者進行驗證以後,當天 Ned Twigg 就合併了補丁並且自動釋出了新版本。我接著在 Flink 提交的補丁上更新了依賴版本,第二天經過 review 以後 Flink 的補丁也合併了。而我本來還以為要到假期後才能搞定。

02 atomic

這個例子是我在 Golang 生態的參與。不同於上一個例子,我在 contribute back 相關改動的時候並沒有急切的下游需求,只是我在使用的過程裡發現上游有可以做得更好的地方,於是就順手實現了。首先講這個例子是因為 uber-go/atomic 的參與經歷給了我一個催釋出的定型文。

我應該是在遷移 TiDB 測試的時候,發現了部分程式碼使用了 Golang 早期只有操作指標的函式的 atomic 庫。這種程式碼要求操作對應的變數必須都用 atomic 庫提供的方法,一旦直接訪問就有破壞一致性的風險。在其他語言的實現裡,往往都會有原子型別,例如 Java 裡的 AtomicInteger  AtomicReference 類,來保證所有操作都是在原子型別的方法上的,也就避免了應用邏輯上是 atomic int64 但是隻能定義成 int64 的問題。

我從其他開發者那裡得知了 Uber 的 atomic 庫提供了原子型別定義,馬上就愉快的用上了。在使用的過程裡,雖然沒有實際的需求,但是從完整性上我發現 Uber 的 atomic 庫沒有定義 uintptr  unsafe.Pointer 對應的原子型別,而 Golang 的 atomic 庫函式裡有操作相應型別的函式。因為閒暇時候我也是寫點程式碼打發時間,所以我先提交 issue 詢問維護者新增這兩個原子型別是否合適,得到肯定的答案之後就順手實現了一個補丁提交。

因為改動很簡單,所以幾輪 review 過後 24 小時內就合併了。

不過,不像 Spotless 通過自動化釋出流水線,合併補丁之後基本一天內就可以從 Maven 中央資源庫引用新版本,大部分的專案包括 Uber 的 atomic 庫都需要人來觸發或完成釋出流程的。非自動化的釋出的節奏,Apache Pulsar 和 Apache Flink 這樣的專案會有一個相對穩定的釋出週期,並且社群成員能夠從文件上看到釋出的時間規則,因此我也能知道大致什麼時間會發布新版本,而且急也沒用。但是有些專案開發活動並不活躍,它們會傾向於開發一段時間後“差不多了就釋出”。

雖然 go.mod 其實允許直接引用一個 commit 標識的版本,但是我出於軟體工程的最佳實踐,我當時認為只有已經正式釋出的版本里包括了我的更改,這個 contribution 才算正式完成。於是我用下面這段話向維護者請求釋出一個包含我的改動的新版本:

@abhinav is there a release cycle or trigger description. I may hurry a bit but it is the nature a developer want to know how and when the work released :P

維護者表示他們確實是“差不多了就釋出”的風格,但是很樂意在當週就釋出一個包含我的改動的新版本。實際上,一天以後就釋出了 v1.8.0 版本。

這段經歷不僅帶給我一個日後重複使用的催釋出定型文,也是在偶然之後提醒我可以更加積極地與維護者溝通:如果你有什麼需求,為什麼要假設其他人不接受,而不是試著問一下呢?

一個有趣的後續是,目前 Golang 最新的 1.19 版本包含了從 Uber 的 atomic 庫“借鑑”的原子型別。雖然我很好奇他們為什麼只選取了一部分型別,比如沒有包括 float 和 duration 等型別,但是我發現我寫的 Uintptr 型別也被包含其中。儘管 Golang 的作者並沒有在程式碼裡說明這段程式碼是來自 Uber 的 atomic 庫的,或許是因為他們覺得這是平凡的實現,在具體的方法集合上也有裁剪,但是 Golang 的開發者都知道是怎麼回事(笑)。

03 Maven Shade Plugin

這個案例的起因是我在 Pulsar 社群恰好和其他幾個開發者同時發現 Pulsar 的 pom.xml 配置觸發了 Maven Shade Plugin 的一個缺陷。

一般來說,Java 開發者對於應用程式碼和庫函式是比較熟悉的,而一旦涉及到構建系統比如 Maven 或 Gradle 等,則會天然地產生一種陌生的刻板印象。這對於其他語言生態也是類似的,C++ 開發者哪怕能夠寫出很複雜的模板程式碼,也很有可能在除錯 CMake 的配置的時候抓瞎。因此,除非專門的構建系統開發者,其他開發者幾乎總是優先考慮繞過問題,而不是懷疑構建系統本身有缺陷,或者把構建系統的缺陷都當成需要理解和共處的特性。

我也不例外。不過我折騰構建系統的時間還算有一些,知道 Maven 增量構建問題一堆,所以馬上發現了執行 mvn clean 清理構建產物以後再跑構建流程(“重啟一下試試”)就可以繞過問題。

然而,一週以後,這個 issue 還是開著,不像有上面的繞過方法就算了的樣子。我一時強迫症上來了,就開始搜尋同類問題。很快,我就發現 Hadoop 和 Elastic 都有人遇到過類似的問題:

不過,他們的解決方法都不是我想要的。Hadoop 的開發者發現先 clean 就行以後就心滿意足的關掉了問題。Elastic 把會導致問題的依賴給重構掉了來繞過這個問題,而 Pulsar 的情形裡這個依賴是無法規避的。

幾乎確定是上游的問題,我在 Maven Shade Plugin 的問題列表上提交了一份報告。沒錯,不像之前幾個例子馬上開始嘗試實現,出於上面提到的開發人員的慣性,我還是下意識地規避構建系統的問題,只是提交一個 issue 並期望上游維護者能夠幫我解決。

不過,提交問題後不久,機緣巧合之下我開始替換 Pulsar Docker Image 構建的 Maven 外掛以支援在 Apple M1 平臺上執行:這或許歸功於公司給我配備的新機器。這個過程裡,我理解了數個構建容器映象的 Maven 外掛具體執行的邏輯,以及為什麼在 Apple M1 上會報出相應的錯誤。這讓我對以前認為看也看不懂並敬而遠之的 Maven Plugin 生態有了新的看法:好像也不怎麼難嘛。

順理成章地,半個月後我看到上面 Shade 外掛的問題還是杳無音訊,就決定動手除錯解決了。

這一次,不像 Spotless 的經歷那樣有現成可以借鑑的程式碼,需要我自己定位問題。不過從除錯 Maven 外掛的經歷裡,我大致知道了 Mojo 抽象的基本概念和執行路徑。從報錯資訊裡定位到相關類以後,我在程式碼中間加入了一系列日誌來列印中間變數。在不好使用 debugger 來劫持執行流程的環境裡,直接用 print 輸出變數值是最值得依賴的手段。

因為問題的表徵是建立 Zip 檔案的時候有重複的被壓縮的 Service 檔案,我重點列印了 Shade 外掛裡合併 Service 檔案時候的檔名,立刻發現被 relocate 的檔案沒有正確合併,而是重複處理了兩次。順著這個事實回過去看 Shade 外掛的程式碼,很容易發現一個基本的邏輯錯誤。一開始,Shade 外掛沒有處理 relocate 規則。後來改了兩次,但都沒改完全。

雖然報告問題以後半個月上游沒有處理,但是我定位了問題,明確分析出原因和提供了易懂的解法以後,加上從 commit 歷史逮捕最近比較活躍的 maintainer at 上,第二天就有兩位 reviewer 參與 review 並且最終合併了我的補丁。這改變了我打破了 Maven 社群的刻板印象。

合併以後,這次確實有下游 Pulsar 的用例等著升級版本來修復問題,因為我自己沒有許可權釋出新版本,所以我再次使用上面提到的定型文催促發布:

@rmannibucau @slawekjaranowski I may be a bit in a hurry but I'd like to know whether/when we can have a release for this fix. It resolves one or several downstream use cases and I'm happy to upgrade for this fix.

It's not a request, though.

兩位 reviewer 告訴我可以到郵件列表上尋求幫助。我就訂閱了 [email protected] 郵件列表,直接請問有沒有維護者願意幫我這個忙。

沒想到 Maven 的 PMC Chair Karl Heinz Marbaise 馬上回復可以在週末的時候發起新版本釋出的投票,最終也確實在當週就釋出完成,我也在下游升級到新版本解決了問題。

這段經歷給我的啟示是,上游優先可以是無處不在的。Hadoop 和 Elastic 社群裡報告問題的人沒有想過相對陌生的構建系統也可以接受補丁修復問題,而是習慣性把它當做一個外部的依賴,一個自己無法干涉的依賴。但是,或許我們還有更好的方式來解決自己的問題。如果一個問題技術上應該在上游解決,為什麼不試著就在上游解決呢?

04 Protobuf

雖然我給 GCP SDK 的 pull request 被掛了幾個月,直到現在也還沒人搭理,但是參與 Google 的另一個開源專案 Protobuf 的體驗還是不錯的。

我在瞎鼓搗 Pulsar Ruby Client 的時候,碰到了 Pulsar 的 Proto 檔案定義的列舉型別內部欄位是小寫字母開頭,而由於 Ruby 沒有列舉型別,Protobuf 把列舉型別的欄位對映成 Ruby 裡的常量,Ruby 的常量又必須是大寫字母開頭,最終導致定義失敗的問題。

雖然這個問題看起來前提條件很複雜,但實際上是一個 Ruby 開發者和 Proto 定義的訊息互動時非常容易遇到的情形。上游在 2016 年就有相關報告:

邏輯上的解法其實很簡單,在定義列舉欄位對映到 Ruby 的常量的時候,自動把欄位名首字母大寫就行了。這樣既不會影響現有程式碼,又能夠解決原來常量定義失敗的故障。雖然對欄位名做了自動調整,但是原本小寫字母開頭的常量定義是失敗的,根本也用不了,而實際到二進位制轉換不看名字只看編號,到文字的轉換走的是符號解析支援小寫字母開頭。

經過這輪分析以後,我確定這個路徑是可以走通的。於是從報錯資訊定位到相關程式碼,把“自動大寫欄位名首字母”的邏輯原地打了個補丁上去。當時我也不懂怎麼觸發測試,也不知道會不會有其他問題,但是先做自己能做的事情,提交到上游讓其他干係人發現有人在努力解決這個問題,並且已經有一些進度了,這能夠在原本大家都觀望的環境裡丟擲一個凝結核,吸引使用者測試補丁和維護者評審程式碼。

不同於 GCP SDK 的原始碼只讀狀況,Protobuf 的維護者隔天就幫我觸發了測試,這讓我感覺到這個社群還是會關注我的工作的。一週以後,Ruby 模組的維護者之一 Jason Lunn 開始 review 我的程式碼,由此開始了近一個月的 review 迴圈。

中間過程我就不再贅述,如果你去看我提交的補丁的對話,你就會發現:

  1. 因為雖然我對這個改動有需求,但是不是特別著急,所以對話經常是以周為單位。每週末我閒著沒事的時候,有時就能想起來還有這件事沒搞完,於是看一下 review 意見和測試結果還有哪些要改的,集中思考和解決一波。
  2. 因為我對 Ruby 並不熟悉,而且一上來搞的就是 Ruby + C 和 JRuby 的超程式設計,所以這個過程裡我其實不是一開始就知道符號的部分不用動,寫出了一堆問題。解決問題的方向錯了,reviewer 好像也沒看出來,大部分時間都是我自己在糾結、測試和補丁之上的補丁,碎碎唸的狀態活像一個孤獨患者自我拉扯。
  3. 因為 contribute code 最好還是本地可以跑全量測試提升反饋效率,所以整個過程下來我把 bazel 這套構建尤其用於 Ruby 專案編譯的各種 trouble shooting 都搞了個遍。以前我總覺得 bazel 的概念晦澀難懂,但是實際直接用起來一個配置好的專案,不僅體驗不錯,還幫我理解了很多設計的原因。
  4. 最後,雖然我在錯誤的道路上走了太遠,甚至一度以為這件事情沒法實現,不過就像我上面對問題的總結,我回歸到一開始要解決的問題,加上一個月來對這段程式碼的深入理解,終於發現了正確的解法,最終用不到 50 行程式碼就把問題給解決了。

程式碼合併以後,我自然是再次用定型文催上游釋出我好早點用上。不過這次上游沒有給我反饋,於是我主動觀察了一下發布的規律,發現 21.x 的版本每半個月到一個月就會發布新版本,然而由於我的補丁只在 master 分支上,只有等到 22.0 釋出的時候才能用上。我覺得這個改動不大,所以就詢問維護者能不能 backport 到 21.x 的分支上趕上下一個短週期的釋出。

另一個維護者 Mike Kruskal 支援這個做法,並且跟我確認了 21.x 和 22.0 的釋出節奏。我得到支援以後就把這個不到 50 行的補丁輕鬆地 backport 到了 21.x 版本,並在三天前得到合併。期待發布中。

這個故事可以拓展成一個典型的上游優先模式。許多程式設計師在面對自己的問題的時候,一開始做出的改動就跟我原地打一個 monkey patch 一樣,對自己的用例有效,其他自己用不到的地方就不管了。但是把自己的修改提交到上游接受評審的時候,才發現原來這個改動可能牽扯到這個那個模組。上游同時被許多下游依賴著,因此它們所選擇的解決方案很可能不是 monkey patch 的方式。通過這樣的上游優先參與,能夠逐漸鍛鍊自己下游使用修改時候符合上游的設計哲學,從而盡力避免由於理念不同而最終不得不分支的情況。

當然,這個例子可能稍顯簡單了。一個年代比較久遠的例子是 2019 年前後我在 Flink 社群參與發起和實現的 FLIP-73FLIP-74  FLIP-85 這三個提案。我在騰訊內部其實做了不一樣的實現,在上游社群和其他 committer 溝通以後形成了最終上游的解決方案。不過關鍵的思路是一樣的,所以內部版本追上上游也不困難,不會因為有截然不同的假設導致被存量拖死。

另外,Protobuf 的問題是 2016 年提出的,今年我解了,參考某司解決一個 etcd 懸掛三年的邊緣問題吹上天,我是不是可以標題黨地寫一個《震驚!他竟然解決了 Protobuf 一個長達六年的痛點!》。

05 Apache Ratis

Ratis 是一個 Raft 演算法的 Java 實現,完成了 Raft 共識演算法的核心邏輯,實現了一個服務框架,包括網路層,日誌同步和落盤的功能,狀態機及其快照的抽象,還支援執行時增減成員、動態配置和同時執行多個 Raft Group 等高階功能。

我對分散式共識演算法的關注由來已久,可能跟我第一個稍有難度的工作就是改良 Flink 基於 ZooKeeper 的高可用模組有關。Ratis 作為共識演算法 Raft 的實現,自然也進入我的視野。

真正開始探索 Ratis 的實現,起源於我從分散式計算做到分散式儲存以後,瞭解到 Spanner、TiKV 和 Oceanbase 等等系統都是基於共識演算法來實現資料的複製和一致性的。在理解相關的程式碼和論文之餘,我也想要自己寫一個個人專案來實踐自己的理解。Ratis 自己曾經想過做一個 Replicated Map 實現,類似於 ZooKeeper 或 etcd 來解決大部分使用者的簡單讀寫場景,但是最終沒做成。我順著這個思路,時不時做一些實驗和原始碼閱讀,並在今年以 zeronos 為專案名開始做一個完整的實現。

在這個過程裡,我發現過 RATIS-1619 Group 建立時約束的問題,順手就解了。這跟前面給 atomic 提補丁沒什麼區別,不做展開。

重點要說的是下面這個例子。我在琢磨怎麼實現類似 etcd 的 watch 功能的時候,發現 Ratis 在框架層面實現了網路通訊,雖然方便了下游只需要定義狀態機就可以起叢集,但是網路通訊只實現了簡單的 request-response 模式,不能照搬 etcd 的全雙工長連線實現。

在之前跟 Ratis 的作者施子和博士的溝通過程裡,我發現主要能找到他的渠道在郵件列表上,於是我就把自己的需求在 [email protected] 上反饋。

果然,施子和博士在一小時內就回復了我的問題。經過幾輪溝通,我們得出結論:watch 和 put 主要的區別在於亂序返回,也就是一個 watch 請求到來之後,必須先處理完後續的寫請求才能出發 watcher 並返回給客戶端。而 Ratis 同一個客戶端的請求預設是排序的。解決了這個問題,只要在請求和返回的時候帶上鍵值狀態機裡 key (range) 的 revision 就能保證獲取變更資訊不會錯過,至於是不是採用全雙工連結來實現,反而不是特別重要。

得出結論以後,我又開始 push 上游推進。當然,開源社群的參與者都是志願者,沒有催促別人做事情的道理。但是我可以做我力所能及的事情,激勵其他參與者發現這件事情有人在關注從而提升優先順序。所以,在兩天之後沒有後續的情況下,我就把 user 郵件列表上的結論總結成一個技術上的問題報告,提交到 Ratis 的 JIRA 專案上。

幾分鐘後,施子和博士在 issue 上問我是否已經開始實現了,我只能坦誠地說沒有。於是他表示他可以實現,並且確實在三天之後就提交了補丁。經過幾輪 review 以後,我 approve 了相關變更。

然後,就是定番詢問釋出的計劃。因為我知道 master 分支上要等 3.0 版本釋出才能用上,而 3.0 釋出還沒確切的日子,相對而言這個改動並不大,包含在 2.x 版本按照以往的節奏一兩個月以內就可以釋出了。施子和博士也同意了這個說法。

這個案例是一個程式碼以外的參與案例。本文開篇就提到,上游優先包括提交程式碼,也包括報告問題。實際上,將自己創作的軟體開源釋出,對於軟體工程師來說很關鍵的一個好處就是收穫同行評審和使用者反饋。相反,僅作公司內部使用的軟體往往少有人關注程式碼的技藝,只關心最終效果,並且通常使用場景有限,很難得到解決複雜問題的鍛鍊機會。

從參與者的角度看,這個案例體現出來的仍然是積極和上游溝通,尤其是做一些力所能及的工作。開源社群的成員都是志願者,他們不會因為某個需求是你提出的就另眼相待。他們幾乎總是從完成需求的難度和回報來衡量自己是否應該投入時間。做一些力所能及的工作,哪怕是前期調研和技術分析,一方面能降低解決問題的難度,另一方面讓其他成員看到你為這個問題付出的努力,你有相當的主動性,解決這個問題能幫助到你。誰又不想和這樣的同伴合作呢?

06 match-template

站在維護者的角度,我也處理過其他社群成員出於上游優先理念提交的請求。

TiKV 的 maintainer @andylokandy 參與到另一個 Rust 資料系統專案 Databend 的開發以後,在實際編碼的過程裡發現自己需要和 TiKV 裡內部的模組 match-template 相似的功能。

雖然 match-template 模組的程式碼不過幾百行,不過 Andy 沒有想著簡單拷貝程式碼,而是希望 TiKV 社群能夠把這個模組 promote 成一個頂級專案,並且在 Rust 的中央資源庫 crates.io 上釋出。

這個提案得到了其他 maintainer 的支援。作為 TiKV Infra Team 的在編人員,我自然很樂意幫忙完成相關設定和釋出的工作。在一個簡單的社群投票之後,我花了一個小時左右的時間把新倉庫建立、match-template 庫釋出和相關許可權設定的工作都做好了。隔天,Andy 就在 Databend 的 PR-6712 裡用上了這個庫。

Andy 其實比我還要晚畢業一年,不過我們都是從剛一畢業甚至還在學校的時候就開始參與開源社群的。開源社群廣泛合作的理念根植在這樣環境裡成長起來的新一代開發者,對於他們(我們)來說,上游優先是再正常不過的事情了。

07 Pulsar Flink Connector

這個例子裡,我同時擁有公司員工和上游維護者兩頂帽子

一方面,作為 Flink Committer 的我是 Flink 社群的維護者,擁有合併補丁的許可權。另一方面,作為公司員工的我因為 Flink 的經歷自然地想要幫助公司業務依賴的 Pulsar Flink Connector 和上游更好的協作。

在我和公司同事 Yufan 的合作下,我們把 Pulsar Connector 的端到端測試覆蓋、一系列缺陷修復和新功能實現推到了上游。由於公司的客戶和 Pulsar 軟體的使用者不都是使用最新版本,我們還把缺陷修復和端到端測試的部分推到了上游維護的其他過往版本。

這可能是公司員工角色的開發者一個典型的特徵。作為個人開發者,往往沒有太多版本依賴包袱,只需要最新版本里有自己提交的補丁,追到最新版本就行。但是在公司裡許多歷史問題累積的應用系統的環境中,激進地升級版本可能會帶來其他問題。因此他們做上游優先的反饋的時候,可能會更加關注修復能否被 backport 到自己使用的版本上。

通過與上游的緊密合作,Pulsar Flink Connector 成為了 Flink 官方維護和釋出的 Connector 的一部分,讓 Flink PMC 為這一軟體背書,從而使得使用者採用的傾向性更強。同時,程式碼進入上游跟隨上游迭代,新增的相關測試保證 Connector 功能的測試被上游迴歸測試所包含。如果上游在核心模組做了影響 Connector 的改動,可以提醒開發者相應調整關聯程式碼,或者至少維護這個 Connector 的 Yufan 能夠知悉。

這也是我時隔一年以後重新參與 Flink 開發活動,並間接導致了閱讀程式碼的過程中發現和解決了上面提到的 Spotless 的問題。可以看到,存在一定使用者基數的開源軟體的生命週期相對都比較長,作為上游維護者,有可能因為公司上游優先的活動而重新回到社群。

這裡值得一提的另一個例子是 Gyula Fora 和 Márton Balassi 發起的 Flink Kubernetes Operator 專案。

Gyula Fora 和 Márton Balassi 是 Flink Streaming API 的核心作者,在 2014 年向 Flink 提交了幾乎改變專案性質的重要改動併成為 PMC 成員之後,他們從 2016 年起幾乎就從上游社群消失了。

今年一月份,這兩位開發者一起加入蘋果公司並開始用 Flink 搭建資料流水線,在生產部署的場景裡遇到了需要 Flink Kubernetes Operator 的需求。由於上游沒有提供相應的軟體,加上蘋果公司的資料流水線只是成本的一部分,並不需要依靠 Flink Kubernetes Operator 來提供商業競爭力,他們於是在公司內部實現了初版以後,就在上游社群提交議案發起新專案。

目前,Flink Kubernetes Operator 已經發布了 1.2.0 版本,並且持續快速迭代中。這個軟體現在不止是蘋果自己在用,螞蟻集團和阿里巴巴集團也開始關注和整合這部分程式碼,回推公司內部的實現,避免內部實現和上游軟體產生方向上分歧。

08 go-redis

最後一個正面的上游優先的故事,我想講一下 Apache Kvrocks (Incubating)  go-redis 相互的合作。

上游優先,進一步分析,並不只是 fork 到 upstream 的方向,也不總是從依賴的軟體到被依賴的軟體的方向;上游優先換個角度看,可以說是一個包含軟體 A 和 B 的整體解決方案,自己作為軟體 A 的開發者,在發現解決方案的部分問題更適合在軟體 B 解決的時候,首先選擇在軟體 B 的範疇內解決,而不是在自己的“領地”裡費盡心思的繞過。

Kvrocks 是一個相容 Redis 協議的分散式 NoSQL 系統,go-redis 是實現 Redis 協議的 Golang 客戶端。我在選型替換 Kvrocks 社群裡無人熟悉的 TCL 測試的時候,最終選擇基於 go-redis 來寫整合測試。

當然,這裡選型的主要是 Golang 語言 Kvrocks 的開發者都比較熟悉,而且寫起測試這樣非關鍵路徑的程式碼簡單粗暴,而 go-redis 是 Redis Golang 客戶端幾乎最好的實現。不過,有一件小事讓我對選用這個軟體更有信心。

偶然之中,我發現 go-redis 其實很早就在 README 裡提及了自己支援訪問 Kvrocks 服務端。由於 Kvrocks 今年四月份捐贈到 Apache 孵化器,我順手改正了上游 README 的表述和連結。go-redis 維護者 @vmihailenco 不僅很快合併了修改,還對 Kvrocks 捐贈這件事表達了祝賀。

當時我就覺得,我很願意和這個人合作。因此我在下面留言說明我將要做的替換工作,而他也很熱心地表示如果出現什麼問題,歡迎和他反饋。

一開始的遷移很順利,我就誇讚了 go-redis 真不錯。隨後一些複雜測試用例的遷移過程裡,我逐漸發現了 go-redis 的一些問題,其中有部分是 Kvrocks 的實現和 Redis 不一致需要改進。每次遇到問題,我基本都會 @vmihailenco 告知他我們用例測出來的問題,並且向他尋求 go-redis 使用上的幫助。

遷移過程中,我還發現 go-redis 沒有實現部分 Redis 命令的封裝介面。跟 @vmihailenco 確認這只是工程工作量太大沒有全部實現,其實是需要的之後,我隨手就提交了一個補丁幫助 go-redis 實現了相關介面。

因為 Kvrocks 可以使用底層介面完成測試,而且我知道上游會在 v9 版本包括這個改動,也就是說釋出是確定的,而我不著急使用,所以這一次我沒有用定型文催促上游明確釋出時間。

這種上游優先是相互的。顯然我為 go-redis 提交了一些程式碼,並且向上遊反饋了不少使用案例,不過 @vmihailenco 也為 Kvrocks 和 Redis 的相容性提供了非常有價值的輸入,並且幫助 review 替換過程中遇到的問題。

應該說,這是開源社群當中的一種常態。大家不是完全基於契約和合同行動,不在契約之內的事情就想方設法避開,而是基於善良和合作的假設,幫助別人,並時不時得到別人的幫助。上下游的合作是相互的,作為積極反饋上游社群的下游,往往也更能得到上游社群反過來的幫助。

09 Trino

第一個印象深刻的遭受挫折的故事,來自給 Trino 提交的一個補丁。

這個補丁來自於 Pulsar 實現 Pulsar Trino Plugin 的過程中發現上游核心模組裡的 AvroColumnDecoder 類功能可以增強。其實在此之前,Pulsar 社群就想過把整個 Pulsar Trino Plugin 捐贈給 Trino 上游維護。

但是 Trino 作為一個公司專案,對待他們眼裡的 “external contributor” 的要求不可謂不高。如果一視同仁,我還可以理解成專案一貫的高要求。然而就像我在 PR-13070 裡回覆的,分明 Starburst 公司自己的員工,就可以分批交付功能,釋出在不同版本上,怎麼到了 “external contributor” 身上,就要我啥都調研一下,啥都嘗試著做呢?

更不用說每次我有什麼回覆,上游沒有個一二十天是不會回覆的,甚至我如果不到 Slack 頻道上 pop 這個合併請求,根本不會有人再看。當然,我個人其實是支援如果你的補丁被上游忽略了,應該儘可能的增大聲量,哪怕上游拒絕合併你的修改,也是一個結果。但是重複做這樣的事情,只會讓我覺得像是一個垃圾資訊製造者。

這就引出來一個要點,上游有可能出於種種原因沒能和你找到一個最大公因數完成上游優先的程式碼反饋,這種情況並不少見,作為下游專案,此時也要有自己的解決方案。

比如,雖然我們說盡可能在上游解決,但是上游已經不維護了,那也只能繞過或者替換了。前面故事裡客串出場的 Docker Maven Plugin 就是我替換已經不維護了的 Dockerfile Maven Plugin 的選擇。儘管我知道可以怎麼修,但是上游也不想修了。

另一個手段是分支。對於上面 Dockerfile Maven Plugin 的例子來說,我也想過分支維護的思路,但是它其實又依賴了同一個組織下另一個已經不維護了的核心庫,這一下維護成本暴漲,我也就沒了興趣。但是下游分支的情況並不少見。比如 Google 的 cpplint 幾乎不維護,就有人拉出一個 cpplint 組織來重新維護這個專案。

回到 Pulsar Trino Plugin 的例子上來,如果上游不接受我回推到核心模組的補丁,大不了我就像現在的實現一樣在 Plugin 裡把我需要的修改後的類單獨寫出來用就是了。雖然這樣有不必要的程式碼重複,但是至少是個方法。進一步的,如果上游不接受 Pulsar Trino Plugin 捐贈,Pulsar 社群也可以自己維護。只不過確實 Trino 的後向相容性極差,如果不能和上游同步迭代發版的話,外部的外掛很可能很難和上游 Trino 服務端保持相容。從使用者層面看,就是同時使用 Pulsar 和 Trino 很難找到一個所有需要的功能都有,而且兩者還能融洽相處的版本向量。

10 goleak

雖然 Trino 的情況讓實踐上游優先策略難度陡增,但是上游優先的失敗案例並不總是上游的問題。

最典型的情況是,如果你對專案的理解和專案維護者不一致,或者說和專案的定位和要解決的問題方向不同,那麼上游優先的反饋就很可能被拒絕。

我在遷移 TiDB 測試的時候,部分工作是使用 goleak 庫來替換手寫的 leaktest 工具。這主要是因為手寫的工具功能有限,甚至後來發現與漏報的情況,而由於沒人維護這個手寫的工具,這些問題長期都無人解決。

替換成 goleak 的過程裡,我發現了一個重複的模式。由於 goleak 的介面設計,在呼叫檢查方法之後,如果失敗就呼叫 os.Exit 直接退出程序,這導致任何 teardown 的邏輯都沒有辦法執行。面對這個問題,我設計了一個 goleak.TestingM 的子型別和一組介面來支援退出邏輯。

這個包裝型別用的還不少,正好 goleak 也是 Uber 開源的 Golang 庫,我前不久在 atomic 庫有不錯的合作體驗,於是就提議直接在上游提供這樣定製退出邏輯的功能。

雖然上游維護者對實現方式有自己的想法,但是我覺得我的實現方式更合適一些。於是我直接懟了一個補丁上去:

然後就是一年杳無音訊。直到上個月一個新加入 Uber 不久的員工按照上游原來的想法實現了這個能力,並把 issue 關聯關閉了。雖然我覺得他的實現其實跟我當時指出的問題一樣,不能很好地在編譯時就阻止錯誤用法,而為了追求介面的“一致性”,有可能在執行時才丟擲異常,但是既然這是上游的決定,這又是個公司專案,我也沒什麼好說的。

當然,哪怕不是公司專案,只要你不能做約束性投票,或者說你的理念不被專案核心維護者認同,那麼下游更改無法推回上游也是很常見的。Apache SkyWalking 的作者吳晟就多次拒絕方向上不符合自己理念的反饋,或者確認問題,但是按照自己的理解來實現。

我自己作為維護者的時候也時有拒絕 contribution 的情況。這其實也是對開源專案維護者的一個要求:你不能對 contribution 來者不拒。開源軟體的製造不是純粹的人多力量大,而是一種精英主義。專案維護者需要依賴自己對專案的設計和理解,有選擇性地吸納社群成員和接受補丁。Linus 曾經說過,他做開源軟體的過程中覺得最愉快的事情,就是可以只跟自己想要合作的人合作。

當然,這就意味著總有一些出於上游優先理念提交補丁或反饋問題的成員得不到正面反饋。但是,失敗並不總是壞事。批判性的看待上游的反饋,我仍然覺得 Trino 的維護者不如我瞭解那部分程式碼,甚至我都解釋累了;goleak 上游的實現我也覺得不如我的視線。還有上游的實現更好,我被折服了也學到許多的例子。

尾聲

上面的故事和經歷包括了十個不同型別的上游優先案例,包括程式碼的非程式碼的、有需求的純順手的、順利的失敗的、自己提交的做 reviewer 的。相信在開源社群當中和他人協作,或者想要參與開源社群的讀者都能從中找到似曾相識的畫面。

希望你在開源參與的過程裡鍛鍊自己的技藝,收穫友誼和成就!