歡迎光臨
每天分享高質量文章

將 5 萬行 Java 程式碼移植到 Go 學到的經驗

  • 原文地址:

    Lessons learned porting 50k loc from Java to Go

  • 原文作者:Krzysztof Kowalczyk
  • 譯文出處: https://blog.kowalczyk.info
  • 本文永久連結:

    https://github.com/gocn/translator/blob/master/2019/w15_lessions_learned_porting_50k_loc_from_java_to_go_translation.md

  • 譯者:cvley
  • 校對:Ryan

     

我曾經簽訂了一個把大型的 Java 程式碼庫遷移至 Go 的工作合同。

這份程式碼是 RavenDB 這一 NoSQL JSON 檔案資料庫的 Java 客戶端。包含測試程式碼,一共有約 5 萬行。

移植的結果是一個 Go 的客戶端。

本文描述了我在這個遷移過程中學到的知識。

測試,程式碼改寫率

自動化測試和程式碼改寫率追蹤,可以讓大型專案獲益匪淺。

我使用 TravisCI 和 AppVeyor 進行測試。Codecov.io 用來檢測程式碼改寫率。還有許多其他的類似服務。

我同時使用 AppVeyor 和 TravisCI,是因為 Travis 在一年前不再支援 Windows,而 AppVeyor 不支援 Linux。

如果現在讓我重新選擇這些工具,我將只使用 AppVeyor,因為它現在支援 Linux 和 Windows 平臺的測試,而 TravisCI 在被私募股權公司收購併炒掉原始開發團隊後,前景並不明朗。

Codecov 幾乎無法勝任程式碼改寫率檢測。對於 Go,它將非程式碼的行(比如註釋)當做是未執行的程式碼。使用這個工具不可能得到 100% 的程式碼改寫率。Coveralls 看起來也有同樣的問題。

聊勝於無,但這些工具可以讓情況變得更好,尤其是對 Go 程式而言。

Go 的競態檢測非常棒

一部分程式碼使用了併發,而併發很容易出錯。

Go 提供了競態檢測器,在編譯時使用 -race 欄位可以開啟它。

它會讓程式變慢,但額外的檢查可以探測是否在同時修改同一個記憶體位置。

我一直開啟 -race 執行測試,透過它的報警,我可以很快地修複那些競爭問題。

構建用於測試的特定工具

大型專案很難透過肉眼檢查驗證正確性。程式碼太多,你的大腦很難一次記住。

當測試失敗時,僅從測試失敗的資訊中找到原因也是一個挑戰。

資料庫客戶端驅動與 RavenDB 資料庫服務端使用 HTTP 協議連線,傳輸的命令和響應的結果使用 JSON 編碼。

當把 Java 測試程式碼移植到 Go 時,如果可以獲取 Java 客戶端與服務端的 HTTP 流量,並與移植到 Go 的程式碼生成的 HTTP 流量對比,這個資訊將非常有用。

我構建了一些特定的工具,幫我完成這些工作。

為了獲取 Java 客戶端的 HTTP 流量,我使用 Go 構建了一個 logging HTTP 代理,Java 客戶端使用這個代理與服務端互動。

對於 Go 客戶端,我構建了一個可以攔截 HTTP 請求的鉤子。我使用它把流量記錄在檔案中。

然後我就可以對比 Java 客戶端與 Go 移植的客戶端生成的 HTTP 流量的區別了。

移植的過程

你不能隨機開始遷移 5 萬行程式碼。我確信,如果每一個小步驟之後不進行測試和驗證的話,我都會被整體程式碼的複雜性給打敗。

對於 RavenDB 和 Java 程式碼庫,我是新手。所以我的第一步是深入理解這份 Java 程式碼的工作原理。

客戶端的核心是與服務端透過 HTTP 協議互動。我捕獲並研究了流量,編寫最簡單的與伺服器互動的 Go 程式碼。

當這麼做有效果之後,我自信可以複製這些功能。

我的第一個裡程碑是移植足夠的程式碼,可以透過移植最簡單的 Java 測試程式碼的測試。

我使用了自底向上和自上到下結合的方法。

自底向上的部分是指,我定位並移植那些用於向伺服器傳送命令和解析響應的呼叫鏈底層的程式碼。

自上到下的部分是指,我逐步跟蹤要移植的測試程式碼,來確定需要移植實現的功能程式碼部分。

在成功完成第一步移植後,剩下的工作就是一次移植一個測試,同時移植可透過這個測試的所有需要的程式碼。

當測試移植並測試透過後,我做了一些讓程式碼更加 Go 風格的改進。

我相信這種一步一步漸進的方法,對於完成移植工作是很重要的。

從心理學角度來看,在面對一個長年累月的專案時,設定簡短的中間態里程碑是很重要的。不斷的完成這些里程碑讓我幹勁十足。

一直讓程式碼保持可編譯、可執行和可透過測試的狀態也很好。當最終要面對那些日積月累的缺陷時,你將很難下手解決。

移植 Java 到 Go 的挑戰

移植的標的是要盡可能與 Java 程式碼庫一致,因為移植的程式碼需要與 Java 未來的變化保持同步。

有時我吃驚於自己以一行一行的方式移植的程式碼量。而移植過程中,最耗費時間的部分是顛倒變數的宣告順序,Java 的宣告順序是 type name,而 Go 的宣告順序是 name type。我真心希望有工具可以幫我完成這部分工作。

String vs. string

在 Java 中,String 是一個本質上是取用(指標)的物件。因此,字串可以為 null

在 Go 中 string 是一個值型別。它不可能是 nil,僅僅為空。

這並不是什麼大問題,大多情況下我可以無腦地將 null 替換為 ""

Errors vs. exceptions

Java 使用異常來傳遞錯誤。

Go 傳回 error 介面的值。

移植不難,但需要修改大量的函式簽名,來支援傳回錯誤值併在呼叫棧上傳播。

泛型

Go (目前)並不支援泛型。

移植泛型的介面是最大的挑戰。

下麵是 Java 中一個泛型方法的例子:

public  load(Class clazz, String id) {

呼叫者:

Foo foo = load(Foo.class, "id")

在 Go 中,我使用兩種策略。

其中之一是使用 interface{},它由值和型別組成,與 Java 中的 object 類似。不推薦使用這種方法。雖然有效,但對於這個庫的使用者而言,操作 interface{} 並不恰當。

在一些情況下我可以使用反射,上面的程式碼可以移植為:

func Load(result interface{}, id string) error

我可以使用反射來獲取 result 的型別,再從 JSON 檔案中建立這個型別的值。

呼叫方的程式碼:

var result *Foo
err := Load(&result;, "id")

函式多載

Go 不支援(很大可能永遠不會支援)函式多載。

我不確定我是否找到了正確的方式來移植這種程式碼。

在一些情況下,多載用於建立更簡短的幫助函式:

void foo(int a, String b) {}
void foo(int a) { foo(a, null); }

有時我會直接丟掉更簡短的幫助函式。

有時我會寫兩個函式:

func foo(a int) {}
func fooWithB(a int, b string) {}

當潛在的引數數量很大時,有時我會這麼做:

type FooArgs struct {
    A int
    B string
}
func foo(args *FooArgs) { }

繼承

Go 並不是面向物件語言,沒有繼承。

簡單情況下的繼承可以使用巢狀的方法移植。

class B : A { }

有時可以移植為:

type A struct { }
type B struct {
    A
}

我們把 A 嵌入到 B 中,因此 B 繼承了 A 所有的方法和欄位。

這種方法對於虛函式無效。

並沒有好方法移植那些使用虛函式的程式碼。

模擬虛函式的一個方式是將結構體和函式指標巢狀。這本質上來說,是重新實現了 Java 免費提供的,作為 object 實現一部分的虛表。

另一種方式是寫一個獨立的函式,透過型別判斷來排程給定型別的正確函式。

介面

Java 和 Go 都有介面,但它們是不一樣的內容,就像蘋果和義大利香腸的區別一樣。

在很少的情況下,我確實會建立 Go 的介面型別來複制 Java 介面。

大多數情況下,我放棄使用介面,而是在 API 中暴露具體的結構體。

依賴包的迴圈引入

Java 允許包的迴圈引入。

Go 不允許。

結果就是,我無法在移植中複製 Java 程式碼的包結構。

為了簡化,我使用一個包。這種方法不太理想,因為這個包最後會變得很臃腫。實際上,這個包臃腫到在 Windows 下 Go 1.10 無法處理單個包內的那麼多源檔案。幸運的是,Go 1.11 修複了這個問題。

私有(private)、公開(public)、保護(protected)

Go 的設計師們被低估了。他們簡化概念的能力是無與倫比的,許可權控制就是其中的一個例子。

其他語言傾向於細粒度的許可權控制:(每個類的欄位和方法)指定最小可能粒度的公開、私有和保護。

結果就是當外部程式碼使用這個庫時,這個庫實現的一些功能和這個庫中其他的類有一樣的訪問許可權。

Go 簡化了這個概念,只擁有公開和私有,訪問的範圍限制在包的級別。

這更合理一些。

當我想要寫一個庫,比如說,解析 markdown,我不想把內部實現暴漏給這個庫的使用者。但對於我自己隱藏這些內部實現,效果恰恰相反。

Java 開發者註意到這個問題,有時會使用介面作為修複過度暴漏的類的技巧。透過傳回一個介面,而不是具體的類,這個類的使用者就無法看到一些可用的公開介面。

併發

簡單來說,Go 的併發是最好的,內建的競態檢測器非常有助於解決併發的問題。

我剛才說過,我進行的第一個移植是模擬 Java 介面。比如,我實現了 Java CompletableFuture 類的複製。

只有在程式碼可以執行後,我才會重新組織程式碼,讓程式碼更加符合 Go 的風格。

流暢的函式鏈式呼叫

RavenDB 擁有複雜的查詢能力。Java 客戶端使用鏈式方法構建查詢:

List results = session.query(User.class)
                        .groupBy("name")
                        .selectKey()
                        .selectCount()
                        .orderByDescending("count")
                        .ofType(ReduceResult.class)
                        .toList();

鏈式呼叫僅在透過異常進行錯誤互動的語言中有效。當一個函式額外傳回一個錯誤,就沒法向上面那樣進行鏈式呼叫。

為了在 Go 中複製鏈式呼叫,我使用了一個“狀態錯誤(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
}

鏈式呼叫可以這麼寫:

var result *Foo
err := NewQuery().WhereEquals("Name""Frank").GroupBy("Age").Execute(&result;)

JSON 解析

Java 沒有內建的 JSON 解析函式,客戶端使用 Jackson JSON 庫。

Go 在標準庫中有 JSON 的支援,但它沒有提供足夠多的鉤子函式來展現 JSON 解析的過程。

我並沒有嘗試匹配所有的 Java 功能,因為 Go 內建的 JSON 支援看起來已經足夠靈活。

Go 程式碼更短

簡短不是 Java 的屬性,而是寫出符合語言習慣程式碼的文化的屬性。

在 Java 中,setter 和 getter 方法很常見。比如,Java 程式碼:

class Foo {
    private int bar;

    public void setBar(int bar) {
        this.bar = bar;
    }

    public int getBar() {
        return this.bar;
    }
}

Go 語言版本如下:

type Foo struct {
    Bar int
}

3 行 vs 11 行。當你有大量的類,類內有很多成員時,這麼做可以不斷累加這些類。

大部分其他的程式碼最後長度基本差不多。

使用 Notion 來組織工作

我是 Notion.so 的重度使用者。用最簡單的話來說,Notion 是一個多級筆記記錄應用。可以把它看做是 Evernote 和 wiki 的結合,是由頂級軟體設計師精心設計和實現的。

下麵是我使用 Notion 組織 Go 移植工作的方式:

下麵是具體的內容:

  • 我有一個沒有在上面展示的帶日曆檢視的頁面,用來記錄在特定時間的工作內容和花費時間的簡短筆記。因為這次合約是按小時收費,所以工作時長的統計是很重要的資訊。感謝這些筆記,我知道我在 11 個月裡在這次開發上花費了 601 個小時。

  • 客戶喜歡瞭解進展。我有一個頁面,記錄了每月的工作總結,如下所示:

這些頁面與客戶共享。

  • 當開始每天的工作時,短期的 todo list 很有用。
  • 我甚至用 Notion 頁面管理髮票,使用“匯出為 PDF”功能來生成發票的 PDF 版本。

待招聘的 Go 程式員

你的公司還需要 Go 開發者嗎?你可以僱用我

額外的資源

針對問題,我提供了一些額外的說明:

  • Hacker News discussion
  • /r/golang discussion

其他資料:

  • 如果你需要一個 NoSQL,JSON 檔案資料庫,可以試一下 RavenDB。它擁有完備的高階特性。
  • 如果你使用 Go 程式設計,可以免費閱讀 Essential Go 這本程式設計書籍。
  • 如果你對 Notion 感興趣,我是 Notion 世界級的高階使用者:
    • 我逆向了 Notion API
    • 我寫了一個 Notion API 的非官方的 Go 庫
    • 本網站的所有內容都是使用 Notion 編寫,並使用我定製化的工具鏈釋出。

已同步到看一看
贊(0)

分享創造快樂