將5萬(wàn)行Java代碼移植到Go學(xué)到的經(jīng)驗(yàn)
我曾經(jīng)簽訂了一個(gè)把大型的 Java 代碼庫(kù)遷移至 Go 的工作合同。
這份代碼是 RavenDB 這一 NoSQL JSON 文檔數(shù)據(jù)庫(kù)的 Java 客戶端。包含測(cè)試代碼,一共有約 5 萬(wàn)行。
移植的結(jié)果是一個(gè) Go 的客戶端。
本文描述了我在這個(gè)遷移過(guò)程中學(xué)到的知識(shí)。
測(cè)試,代碼覆蓋率
自動(dòng)化測(cè)試和代碼覆蓋率追蹤,可以讓大型項(xiàng)目獲益匪淺。
我使用 TravisCI 和 AppVeyor 進(jìn)行測(cè)試。Codecov.io 用來(lái)檢測(cè)代碼覆蓋率。還有許多其他的類(lèi)似服務(wù)。
我同時(shí)使用 AppVeyor 和 TravisCI,是因?yàn)?Travis 在一年前不再支持 Windows,而 AppVeyor 不支持 Linux。
如果現(xiàn)在讓我重新選擇這些工具,我將只使用 AppVeyor,因?yàn)樗F(xiàn)在支持 Linux 和 Windows 平臺(tái)的測(cè)試,而 TravisCI 在被私募股權(quán)公司收購(gòu)并炒掉原始開(kāi)發(fā)團(tuán)隊(duì)后,前景并不明朗。
Codecov 幾乎無(wú)法勝任代碼覆蓋率檢測(cè)。對(duì)于 Go,它將非代碼的行(比如注釋)當(dāng)做是未執(zhí)行的代碼。使用這個(gè)工具不可能得到 100% 的代碼覆蓋率。Coveralls 看起來(lái)也有同樣的問(wèn)題。
聊勝于無(wú),但這些工具可以讓情況變得更好,尤其是對(duì) Go 程序而言。
Go 的競(jìng)態(tài)檢測(cè)非常棒
一部分代碼使用了并發(fā),而并發(fā)很容易出錯(cuò)。
Go 提供了競(jìng)態(tài)檢測(cè)器,在編譯時(shí)使用 -race 字段可以開(kāi)啟它。
它會(huì)讓程序變慢,但額外的檢查可以探測(cè)是否在同時(shí)修改同一個(gè)內(nèi)存位置。
我一直開(kāi)啟 -race 運(yùn)行測(cè)試,通過(guò)它的報(bào)警,我可以很快地修復(fù)那些競(jìng)爭(zhēng)問(wèn)題。
構(gòu)建用于測(cè)試的特定工具
大型項(xiàng)目很難通過(guò)肉眼檢查驗(yàn)證正確性。代碼太多,你的大腦很難一次記住。
當(dāng)測(cè)試失敗時(shí),僅從測(cè)試失敗的信息中找到原因也是一個(gè)挑戰(zhàn)。
數(shù)據(jù)庫(kù)客戶端驅(qū)動(dòng)與 RavenDB 數(shù)據(jù)庫(kù)服務(wù)端使用 HTTP 協(xié)議連接,傳輸?shù)拿詈晚憫?yīng)的結(jié)果使用 JSON 編碼。
當(dāng)把 Java 測(cè)試代碼移植到 Go 時(shí),如果可以獲取 Java 客戶端與服務(wù)端的 HTTP 流量,并與移植到 Go 的代碼生成的 HTTP 流量對(duì)比,這個(gè)信息將非常有用。
我構(gòu)建了一些特定的工具,幫我完成這些工作。
為了獲取 Java 客戶端的 HTTP 流量,我使用 Go 構(gòu)建了一個(gè) logging HTTP 代理,Java 客戶端使用這個(gè)代理與服務(wù)端交互。
對(duì)于 Go 客戶端,我構(gòu)建了一個(gè)可以攔截 HTTP 請(qǐng)求的鉤子。我使用它把流量記錄在文件中。
然后我就可以對(duì)比 Java 客戶端與 Go 移植的客戶端生成的 HTTP 流量的區(qū)別了。
移植的過(guò)程
你不能隨機(jī)開(kāi)始遷移 5 萬(wàn)行代碼。我確信,如果每一個(gè)小步驟之后不進(jìn)行測(cè)試和驗(yàn)證的話,我都會(huì)被整體代碼的復(fù)雜性給打敗。
對(duì)于 RavenDB 和 Java 代碼庫(kù),我是新手。所以我的***步是深入理解這份 Java 代碼的工作原理。
客戶端的核心是與服務(wù)端通過(guò) HTTP 協(xié)議交互。我捕獲并研究了流量,編寫(xiě)最簡(jiǎn)單的與服務(wù)器交互的 Go 代碼。
當(dāng)這么做有效果之后,我自信可以復(fù)制這些功能。
我的***個(gè)里程碑是移植足夠的代碼,可以通過(guò)移植最簡(jiǎn)單的 Java 測(cè)試代碼的測(cè)試。
我使用了自底向上和自上到下結(jié)合的方法。
自底向上的部分是指,我定位并移植那些用于向服務(wù)器發(fā)送命令和解析響應(yīng)的調(diào)用鏈底層的代碼。
自上到下的部分是指,我逐步跟蹤要移植的測(cè)試代碼,來(lái)確定需要移植實(shí)現(xiàn)的功能代碼部分。
在成功完成***步移植后,剩下的工作就是一次移植一個(gè)測(cè)試,同時(shí)移植可通過(guò)這個(gè)測(cè)試的所有需要的代碼。
當(dāng)測(cè)試移植并測(cè)試通過(guò)后,我做了一些讓代碼更加 Go 風(fēng)格的改進(jìn)。
我相信這種一步一步漸進(jìn)的方法,對(duì)于完成移植工作是很重要的。
從心理學(xué)角度來(lái)看,在面對(duì)一個(gè)長(zhǎng)年累月的項(xiàng)目時(shí),設(shè)置簡(jiǎn)短的中間態(tài)里程碑是很重要的。不斷的完成這些里程碑讓我干勁十足。
一直讓代碼保持可編譯、可運(yùn)行和可通過(guò)測(cè)試的狀態(tài)也很好。當(dāng)最終要面對(duì)那些日積月累的缺陷時(shí),你將很難下手解決。
移植 Java 到 Go 的挑戰(zhàn)
移植的目標(biāo)是要盡可能與 Java 代碼庫(kù)一致,因?yàn)橐浦驳拇a需要與 Java 未來(lái)的變化保持同步。
有時(shí)我吃驚于自己以一行一行的方式移植的代碼量。而移植過(guò)程中,最耗費(fèi)時(shí)間的部分是顛倒變量的聲明順序,Java 的聲明順序是 type name ,而 Go 的聲明順序是 name type 。我真心希望有工具可以幫我完成這部分工作。
String vs. string
在 Java 中, String 是一個(gè)本質(zhì)上是引用(指針)的對(duì)象。因此,字符串可以為 null 。
在 Go 中 string 是一個(gè)值類(lèi)型。它不可能是 nil ,僅僅為空。
這并不是什么大問(wèn)題,大多情況下我可以無(wú)腦地將 null 替換為 "" 。
Errors vs. exceptions
Java 使用異常來(lái)傳遞錯(cuò)誤。
Go 返回 error 接口的值。
移植不難,但需要修改大量的函數(shù)簽名,來(lái)支持返回錯(cuò)誤值并在調(diào)用棧上傳播。
泛型
Go (目前)并不支持泛型。
移植泛型的接口是***的挑戰(zhàn)。
下面是 Java 中一個(gè)泛型方法的例子:
- public <T> T load(Class<T> clazz, String id) {
調(diào)用者:
- Foo foo = load(Foo.class, "id")
在 Go 中,我使用兩種策略。
其中之一是使用 interface{} ,它由值和類(lèi)型組成,與 Java 中的 object 類(lèi)似。不推薦使用這種方法。雖然有效,但對(duì)于這個(gè)庫(kù)的用戶而言,操作 interface{} 并不恰當(dāng)。
在一些情況下我可以使用反射,上面的代碼可以移植為:
- func Load(result interface{}, id string) error
我可以使用反射來(lái)獲取 result 的類(lèi)型,再?gòu)?JSON 文檔中創(chuàng)建這個(gè)類(lèi)型的值。
調(diào)用方的代碼:
- var result *Foo
- err := Load(&result, "id")
函數(shù)重載
Go 不支持(很大可能永遠(yuǎn)不會(huì)支持)函數(shù)重載。
我不確定我是否找到了正確的方式來(lái)移植這種代碼。
在一些情況下,重載用于創(chuàng)建更簡(jiǎn)短的幫助函數(shù):
- void foo(int a, String b) {}
- void foo(int a) { foo(a, null); }
有時(shí)我會(huì)直接丟掉更簡(jiǎn)短的幫助函數(shù)。
有時(shí)我會(huì)寫(xiě)兩個(gè)函數(shù):
- func foo(a int) {}
- func fooWithB(a int, b string) {}
當(dāng)潛在的參數(shù)數(shù)量很大時(shí),有時(shí)我會(huì)這么做:
- type FooArgs struct {
- A int
- B string
- }
- func foo(args *FooArgs) { }
繼承
Go 并不是面向?qū)ο笳Z(yǔ)言,沒(méi)有繼承。
簡(jiǎn)單情況下的繼承可以使用嵌套的方法移植。
- class B : A { }
有時(shí)可以移植為:
- type A struct { }
- type B struct {
- A
- }
我們把 A 嵌入到 B 中,因此 B 繼承了 A 所有的方法和字段。
這種方法對(duì)于虛函數(shù)無(wú)效。
并沒(méi)有好方法移植那些使用虛函數(shù)的代碼。
模擬虛函數(shù)的一個(gè)方式是將結(jié)構(gòu)體和函數(shù)指針嵌套。這本質(zhì)上來(lái)說(shuō),是重新實(shí)現(xiàn)了 Java 免費(fèi)提供的,作為 object 實(shí)現(xiàn)一部分的虛表。
另一種方式是寫(xiě)一個(gè)獨(dú)立的函數(shù),通過(guò)類(lèi)型判斷來(lái)調(diào)度給定類(lèi)型的正確函數(shù)。
接口
Java 和 Go 都有接口,但它們是不一樣的內(nèi)容,就像蘋(píng)果和意大利香腸的區(qū)別一樣。
在很少的情況下,我確實(shí)會(huì)創(chuàng)建 Go 的接口類(lèi)型來(lái)復(fù)制 Java 接口。
大多數(shù)情況下,我放棄使用接口,而是在 API 中暴露具體的結(jié)構(gòu)體。
依賴(lài)包的循環(huán)引入
Java 允許包的循環(huán)引入。
Go 不允許。
結(jié)果就是,我無(wú)法在移植中復(fù)制 Java 代碼的包結(jié)構(gòu)。
為了簡(jiǎn)化,我使用一個(gè)包。這種方法不太理想,因?yàn)檫@個(gè)包***會(huì)變得很臃腫。實(shí)際上,這個(gè)包臃腫到在 Windows 下 Go 1.10 無(wú)法處理單個(gè)包內(nèi)的那么多源文件。幸運(yùn)的是,Go 1.11 修復(fù)了這個(gè)問(wèn)題。
私有(private)、公開(kāi)(public)、保護(hù)(protected)
Go 的設(shè)計(jì)師們被低估了。他們簡(jiǎn)化概念的能力是***的,權(quán)限控制就是其中的一個(gè)例子。
其他語(yǔ)言傾向于細(xì)粒度的權(quán)限控制:(每個(gè)類(lèi)的字段和方法)指定最小可能粒度的公開(kāi)、私有和保護(hù)。
結(jié)果就是當(dāng)外部代碼使用這個(gè)庫(kù)時(shí),這個(gè)庫(kù)實(shí)現(xiàn)的一些功能和這個(gè)庫(kù)中其他的類(lèi)有一樣的訪問(wèn)權(quán)限。
Go 簡(jiǎn)化了這個(gè)概念,只擁有公開(kāi)和私有,訪問(wèn)的范圍限制在包的級(jí)別。
這更合理一些。
當(dāng)我想要寫(xiě)一個(gè)庫(kù),比如說(shuō),解析 markdown,我不想把內(nèi)部實(shí)現(xiàn)暴漏給這個(gè)庫(kù)的使用者。但對(duì)于我自己隱藏這些內(nèi)部實(shí)現(xiàn),效果恰恰相反。
Java 開(kāi)發(fā)者注意到這個(gè)問(wèn)題,有時(shí)會(huì)使用接口作為修復(fù)過(guò)度暴漏的類(lèi)的技巧。通過(guò)返回一個(gè)接口,而不是具體的類(lèi),這個(gè)類(lèi)的使用者就無(wú)法看到一些可用的公開(kāi)接口。
并發(fā)
簡(jiǎn)單來(lái)說(shuō),Go 的并發(fā)是***的,內(nèi)建的競(jìng)態(tài)檢測(cè)器非常有助于解決并發(fā)的問(wèn)題。
我剛才說(shuō)過(guò),我進(jìn)行的***個(gè)移植是模擬 Java 接口。比如,我實(shí)現(xiàn)了 Java CompletableFuture 類(lèi)的復(fù)制。
只有在代碼可以運(yùn)行后,我才會(huì)重新組織代碼,讓代碼更加符合 Go 的風(fēng)格。
流暢的函數(shù)鏈?zhǔn)秸{(diào)用
RavenDB 擁有復(fù)雜的查詢能力。Java 客戶端使用鏈?zhǔn)椒椒?gòu)建查詢:
- List<ReduceResult> results = session.query(User.class)
- .groupBy("name")
- .selectKey()
- .selectCount()
- .orderByDescending("count")
- .ofType(ReduceResult.class)
- .toList();
鏈?zhǔn)秸{(diào)用僅在通過(guò)異常進(jìn)行錯(cuò)誤交互的語(yǔ)言中有效。當(dāng)一個(gè)函數(shù)額外返回一個(gè)錯(cuò)誤,就沒(méi)法向上面那樣進(jìn)行鏈?zhǔn)秸{(diào)用。
為了在 Go 中復(fù)制鏈?zhǔn)秸{(diào)用,我使用了一個(gè)“狀態(tài)錯(cuò)誤(stateful error)”的方法:
- type Query struct {
- err error
- }
- func (q *Query) WhereEquals(field string, val interface{}) *Query {
- if q.err != nil {
- return q
- }
- // logic that might set q.err
- return q
- }
- func (q *Query) GroupBy(field string) *Query {
- if q.err != nil {
- return q
- }
- // logic that might set q.err
- return q
- }
- func (q *Query) Execute(result inteface{}) error {
- if q.err != nil {
- return q.err
- }
- // do logic
- }
鏈?zhǔn)秸{(diào)用可以這么寫(xiě):
- var result *Foo
- err := NewQuery().WhereEquals("Name", "Frank").GroupBy("Age").Execute(&result)
JSON 解析
Java 沒(méi)有內(nèi)建的 JSON 解析函數(shù),客戶端使用 Jackson JSON 庫(kù)。
Go 在標(biāo)準(zhǔn)庫(kù)中有 JSON 的支持,但它沒(méi)有提供足夠多的鉤子函數(shù)來(lái)展現(xiàn) JSON 解析的過(guò)程。
我并沒(méi)有嘗試匹配所有的 Java 功能,因?yàn)?Go 內(nèi)置的 JSON 支持看起來(lái)已經(jīng)足夠靈活。
Go 代碼更短
簡(jiǎn)短不是 Java 的屬性,而是寫(xiě)出符合語(yǔ)言習(xí)慣代碼的文化的屬性。
在 Java 中,setter 和 getter 方法很常見(jiàn)。比如,Java 代碼:
- class Foo {
- private int bar;
- public void setBar(int bar) {
- this.bar = bar;
- }
- public int getBar() {
- return this.bar;
- }
- }
Go 語(yǔ)言版本如下:
- type Foo struct {
- Bar int
- }
3 行 vs 11 行。當(dāng)你有大量的類(lèi),類(lèi)內(nèi)有很多成員時(shí),這么做可以不斷累加這些類(lèi)。
大部分其他的代碼***長(zhǎng)度基本差不多。
使用 Notion 來(lái)組織工作
我是 Notion.so 的重度用戶。用最簡(jiǎn)單的話來(lái)說(shuō),Notion 是一個(gè)多級(jí)筆記記錄應(yīng)用。可以把它看做是 Evernote 和 wiki 的結(jié)合,是由***軟件設(shè)計(jì)師精心設(shè)計(jì)和實(shí)現(xiàn)的。
下面是我使用 Notion 組織 Go 移植工作的方式:
下面是具體的內(nèi)容:
我有一個(gè)沒(méi)有在上面展示的帶日歷視圖的頁(yè)面,用來(lái)記錄在特定時(shí)間的工作內(nèi)容和花費(fèi)時(shí)間的簡(jiǎn)短筆記。因?yàn)檫@次合約是按小時(shí)收費(fèi),所以工作時(shí)長(zhǎng)的統(tǒng)計(jì)是很重要的信息。感謝這些筆記,我知道我在 11 個(gè)月里在這次開(kāi)發(fā)上花費(fèi)了 601 個(gè)小時(shí)。
客戶喜歡了解進(jìn)展。我有一個(gè)頁(yè)面,記錄了每月的工作總結(jié),如下所示:
這些頁(yè)面與客戶共享。
- 當(dāng)開(kāi)始每天的工作時(shí),短期的 todo list 很有用。
- 我甚至用 Notion 頁(yè)面管理發(fā)票,使用“導(dǎo)出為 PDF”功能來(lái)生成發(fā)票的 PDF 版本。
待招聘的 Go 程序員
你的公司還需要 Go 開(kāi)發(fā)者嗎?你可以雇用我
額外的資源
針對(duì)問(wèn)題,我提供了一些額外的說(shuō)明:
- Hacker News discussion
- /r/golang discussion
其他資料:
- 如果你需要一個(gè) NoSQL,JSON 文檔數(shù)據(jù)庫(kù),可以試一下 RavenDB。它擁有完備的高級(jí)特性。
- 如果你使用 Go 編程,可以免費(fèi)閱讀 Essential Go 這本編程書(shū)籍。
- 如果你對(duì) Notion 感興趣,我是 Notion ***的高級(jí)用戶:
- 我逆向了 Notion API
- 我寫(xiě)了一個(gè) Notion API 的非官方的 Go 庫(kù)
- 本網(wǎng)站的所有內(nèi)容都是使用 Notion 編寫(xiě),并使用我定制化的工具鏈發(fā)布。