Golang 中的良好代碼與糟糕代碼
最近,有人要求我詳細解釋在 Golang 中什么是好的代碼和壞的代碼。我覺得這個練習(xí)非常有趣。實際上,足夠有趣以至于我寫了一篇關(guān)于這個話題的文章。為了說明我的回答,我選擇了我在空中交通管理(ATM)領(lǐng)域遇到的一個具體用例。
一、背景
首先,簡要解釋一下實現(xiàn)的背景。
歐洲航空管制組織(Eurocontrol)是管理歐洲各國航空交通的組織。Eurocontrol 與航空導(dǎo)航服務(wù)提供商(ANSP)之間交換數(shù)據(jù)的通用網(wǎng)絡(luò)稱為 AFTN。這個網(wǎng)絡(luò)主要用于交換兩種不同類型的消息:ADEXP 和 ICAO 消息。每種消息類型都有自己的語法,但在語義上,這兩種類型是等價的(或多或少)。在這個上下文中,性能 必須是實現(xiàn)的關(guān)鍵要素。
該項目需要提供兩種基于 Go 解析 ADEXP 消息的實現(xiàn)(ICAO 沒有在這個練習(xí)中處理):
- 一個糟糕的實現(xiàn)(包名:bad)
- 一個重構(gòu)后的實現(xiàn)(包名:good)
可以在 這里 找到 ADEXP 消息的示例。
在這個練習(xí)中,解析器只處理了 ADEXP 消息中的一部分字段。但這仍然是相關(guān)的,因為它可以說明常見的 Golang 錯誤。
二、解析
簡而言之,ADEXP 消息是一組令牌。令牌類型可以是:一組令牌的重復(fù)列表。每行包含一組令牌子列表(在本示例中為 GEOID、LATTD、LONGTD)。
考慮到這個背景,重要的是要實現(xiàn)一個可以利用并行性的版本。所以算法如下:
- 預(yù)處理步驟來清理和重新排列輸入消息(我們必須清除潛在的空格,重新排列多行的令牌,如 COMMENT 等)。
- 然后在一個給定的 goroutine 中拆分每一行。每個 goroutine 將負責處理一行并返回結(jié)果。
- 最后,收集結(jié)果并返回一個 Message 結(jié)構(gòu)。這個結(jié)構(gòu)是一個通用的結(jié)構(gòu),無論消息類型是 ADEXP 還是 ICAO。
每個包都包含一個 adexp.go 文件,暴露了主要的函數(shù) ParseAdexpMessage()。
三、逐步比較
現(xiàn)在,讓我們逐步看看我認為是糟糕代碼的部分,以及我是如何重構(gòu)它的。
1.字符串 vs []byte
糟糕的實現(xiàn)僅處理字符串輸入。由于 Go 提供了對字節(jié)操作的強大支持(基本操作如修剪、正則表達式等),并且考慮到輸入很可能是 []byte(考慮到 AFTN 消息是通過 TCP 接收的),實際上沒有理由強制使用字符串輸入。
2.錯誤處理
糟糕的實現(xiàn)中的錯誤處理有些糟糕。 我們可以找到一些潛在錯誤返回的情況,而第二個參數(shù)中的錯誤甚至沒有被處理:
preprocessed, _ := preprocess(string)
優(yōu)秀的實現(xiàn)處理了每一個可能的錯誤:
preprocessed, err := preprocess(bytes)
if err != nil {
return Message{}, err
}
我們還可以在糟糕的實現(xiàn)中找到一些錯誤,就像下面的代碼中所示:
if len(in) == 0 {
return "", fmt.Errorf("Input is empty")
}
第一個錯誤是語法錯誤。根據(jù) Go 的規(guī)范,錯誤字符串既不應(yīng)該大寫,也不應(yīng)該以標點結(jié)束。
第二個錯誤是因為如果一個錯誤字符串是一個簡單的常量(不需要格式化),使用 errors.New() 更為高效。
優(yōu)秀的實現(xiàn)看起來是這樣的:
if len(in) == 0 {
return nil, errors.New("input is empty")
}
3.避免嵌套
mapLine() 函數(shù)是一個避免嵌套調(diào)用的良好示例。糟糕的實現(xiàn):
func mapLine(msg *Message, in string, ch chan string) {
if !startWith(in, stringComment) {
token, value := parseLine(in)
if token != "" {
f, contains := factory[string(token)]
if !contains {
ch <- "ok"
} else {
data := f(token, value)
enrichMessage(msg, data)
ch <- "ok"
}
} else {
ch <- "ok"
return
}
} else {
ch <- "ok"
return
}
}
相反,優(yōu)秀的實現(xiàn)是一個扁平的表示方式:
func mapLine(in []byte, ch chan interface{}) {
// Filter empty lines and comment lines
if len(in) == 0 || startWith(in, bytesComment) {
ch <- nil
return
}
token, value := parseLine(in)
if token == nil {
ch <- nil
log.Warnf("Token name is empty on line %v", string(in))
return
}
sToken := string(token)
if f, contains := factory[sToken]; contains {
ch <- f(sToken, value)
return
}
log.Warnf("Token %v is not managed by the parser", string(in))
ch <- nil
}
這樣做在我看來使代碼更易讀。此外,這種扁平的表示方式也必須應(yīng)用到錯誤管理中。舉個例子:
a, err := f1()
if err == nil {
b, err := f2()
if err == nil {
return b, nil
} else {
return nil, err
}
} else {
return nil, err
}
應(yīng)該被替換為:
a, err := f1()
if err != nil {
return nil, err
}
b, err := f2()
if err != nil {
return nil, err
}
return b, nil
再次,第二個代碼版本更容易閱讀。
4.傳遞數(shù)據(jù)是按引用還是按值傳遞
在糟糕的實現(xiàn)中,預(yù)處理函數(shù)的簽名是:
func preprocess(in container) (container, error) {
}
考慮到這個項目的背景(性能很重要),并考慮到消息可能會相當龐大,更好的選擇是傳遞對容器結(jié)構(gòu)的指針。否則,在先前的示例中,每次調(diào)用都會復(fù)制容器值。
優(yōu)秀的實現(xiàn)并不面臨這個問題,因為它處理切片(無論底層數(shù)據(jù)如何,都是一個簡單的 24 字節(jié)結(jié)構(gòu))。
func preprocess(in []byte) ([][]byte, error) {
}
糟糕的實現(xiàn)基于一個很好的初始想法:利用 goroutine 并行處理數(shù)據(jù)(每行一個 goroutine)。
這是通過在循環(huán)遍歷行數(shù)的過程中,為每一行啟動一個 mapLine() 調(diào)用的 goroutine 完成的。
for i := 0; i < len(lines); i++ {
go mapLine(&msg, lines[i], ch)
}
因為結(jié)構(gòu)中包含一些切片,這些切片可能會被并發(fā)地修改(由兩個或更多的 goroutine 同時修改),在糟糕的實現(xiàn)中,我們不得不處理互斥鎖。
例如,Message 結(jié)構(gòu)包含一個 Estdata []estdata。 通過添加另一個 estdata 來修改切片必須這樣做:
mutexEstdata.Lock()
for _, v := range value {
fl := extractFlightLevel(v[subtokenFl])
msg.Estdata = append(msg.Estdata, estdata{v[subtokenPtid], v[subtokenEto], fl})
}
mutexEstdata.Unlock()
現(xiàn)實情況是,除非是非常特殊的用例,必須在 goroutine 中使用互斥鎖可能是代碼存在問題的跡象。
5.缺點 #2:偽共享
跨線程/協(xié)程共享內(nèi)存并不是一個好主意,因為可能存在偽共享(一個 CPU 核心緩存中的緩存行可能會被另一個 CPU 核心緩存無效)。這意味著,如果線程/協(xié)程意圖對其進行更改,我們應(yīng)該盡量避免在線程/協(xié)程之間共享相同的變量。
在這個例子中,我認為偽共享影響不大,因為輸入文件相當輕量級(在 Message 結(jié)構(gòu)中添加填充字段并進行性能測試得到的結(jié)果大致相同)。然而,在我看來,這始終是一件需要牢記的重要事情。
現(xiàn)在讓我們看一下好的實現(xiàn)是如何處理并行處理的:
for _, line := range in {
go mapLine(line, ch)
}
現(xiàn)在,mapLine() 只接收兩個輸入:
- 當前行
- 一個通道。這次,這個通道不僅用于在行處理完成時發(fā)送通知,還用于發(fā)送實際結(jié)果。這意味著不應(yīng)該由 goroutine 來修改最終的 Message 結(jié)構(gòu)。
父 goroutine(生成單獨的 goroutine 中的 mapLine() 調(diào)用的那個)通過以下方式收集結(jié)果:
msg := Message{}
for range in {
data := <-ch
switch data.(type) {
// Modify msg variable
}
}
這個實現(xiàn)更符合 Go 的原則,只通過通信來共享內(nèi)存。Message 變量由單個 Goroutine 修改,以防止?jié)撛诘牟l(fā)切片修改和錯誤共享。
即使是好的代碼也可能面臨一個潛在的批評,就是為每一行代碼都創(chuàng)建一個 Goroutine。這樣的實現(xiàn)可以工作,因為 ADEXP 消息不會包含成千上萬行的內(nèi)容。然而,在非常高的吞吐量下,簡單的實現(xiàn)每個請求觸發(fā)一個 Goroutine 的方式并不具有很強的可擴展性。更好的選擇可能是創(chuàng)建一個可重用 Goroutine 池。
編輯: 假設(shè)(一行代碼 = 一個 Goroutine)絕對不是一個好主意,因為它會導(dǎo)致過多的上下文切換。要獲取更多信息,請查看 further reading 章節(jié)末尾的鏈接。
6.處理行的通知
在不好的實現(xiàn)中,如上所述,一旦通過 mapLine() 完成行處理,我們應(yīng)該通知父 Goroutine。這是通過使用 chan string 通道和調(diào)用來實現(xiàn)的:
ch <- "ok"
對于父 Goroutine 實際上并不檢查通道發(fā)送的值,更好的選擇是使用 chan struct{},使用 ch <- struct{}{},甚至更好(對 GC 更友好)的選擇是使用 chan interface{},使用 ch <- nil。
另一種方法(在我看來更清晰的方法)是使用 sync.WaitGroup,因為父 Goroutine 只需在每個 mapLine() 完成后繼續(xù)執(zhí)行。
7.If
Go 語言的 if 語句允許在條件之前傳遞一個語句。
對于這段代碼的改進版本:
f, contains := factory[string(token)]
if contains {
// Do something
}
以下實現(xiàn)可以是這樣的:
if f, contains := factory[sToken]; contains {
// Do something
}
它稍微提高了代碼的可讀性。
8.Switch
另一個糟糕實現(xiàn)的錯誤是在以下開關(guān)語句中忘記了默認情況:
switch simpleToken.token {
case tokenTitle:
msg.Title = value
case tokenAdep:
msg.Adep = value
case tokenAltnz:
msg.Alternate = value
// Other cases
}
如果開發(fā)者考慮了所有不同的情況,那么默認情況可以是可選的。然而,像以下示例中這樣捕捉特定情況肯定更好:
switch simpleToken.token {
case tokenTitle:
msg.Title = value
case tokenAdep:
msg.Adep = value
case tokenAltnz:
msg.Alternate = value
// Other cases
default:
log.Errorf("unexpected token type %v", simpleToken.token)
return Message{}, fmt.Errorf("unexpected token type %v", simpleToken.token)
}
處理默認情況有助于在開發(fā)過程中盡快捕獲開發(fā)人員可能產(chǎn)生的潛在錯誤。
9.遞歸
parseComplexLines() 是一個解析復(fù)雜標記的函數(shù)。糟糕代碼中的算法是使用遞歸完成的:
func parseComplexLines(in string, currentMap map[string]string,
out []map[string]string) []map[string]string {
match := regexpSubfield.Find([]byte(in))
if match == nil {
out = append(out, currentMap)
return out
}
sub := string(match)
h, l := parseLine(sub)
_, contains := currentMap[string(h)]
if contains {
out = append(out, currentMap)
currentMap = make(map[string]string)
}
currentMap[string(h)] = string(strings.Trim(l, stringEmpty))
return parseComplexLines(in[len(sub):], currentMap, out)
}
然而,Go 不支持尾遞歸消除以優(yōu)化子函數(shù)調(diào)用。良好的代碼產(chǎn)生完全相同的結(jié)果,但使用迭代算法:
func parseComplexToken(token string, value []byte) interface{} {
if value == nil {
log.Warnf("Empty value")
return complexToken{token, nil}
}
var v []map[string]string
currentMap := make(map[string]string)
matches := regexpSubfield.FindAll(value, -1)
for _, sub := range matches {
h, l := parseLine(sub)
if _, contains := currentMap[string(h)]; contains {
v = append(v, currentMap)
currentMap = make(map[string]string)
}
currentMap[string(h)] = string(bytes.Trim(l, stringEmpty))
}
v = append(v, currentMap)
return complexToken{token, v}
}
第二段代碼將比第一段代碼更高效。
10.常量管理
我們必須管理一個常量值以區(qū)分 ADEXP 和 ICAO 消息。糟糕的代碼是這樣做的:
const (
AdexpType = 0 // TODO constant
IcaoType = 1
)
而良好的代碼是基于 Go(優(yōu)雅的)iota 的更優(yōu)雅的解決方案:
const (
AdexpType = iota
IcaoType
)
它產(chǎn)生完全相同的結(jié)果,但減少了潛在的開發(fā)人員錯誤。
11.接收器函數(shù)
每個解析器提供一個函數(shù)來確定消息是否涉及更高級別(至少有一個路由點在 350 級以上)。
糟糕的代碼是這樣實現(xiàn)的:
func IsUpperLevel(m Message) bool {
for _, r := range m.RoutePoints {
if r.FlightLevel > upperLevel {
return true
}
}
return false
}
意味著我們必須將消息作為函數(shù)的輸入?yún)?shù)傳遞。 而良好的代碼只是一個帶有消息接收器的函數(shù):
func (m *Message) IsUpperLevel() bool {
for _, r := range m.RoutePoints {
if r.FlightLevel > upperLevel {
return true
}
}
return false
}
第二種方法更可取。我們只需指示消息結(jié)構(gòu)實現(xiàn)了特定的行為。
這也可能是使用 Go 接口的第一步。例如,如果將來我們需要創(chuàng)建另一個具有相同行為(IsUpperLevel())的結(jié)構(gòu)體,初始代碼甚至不需要重構(gòu)(因為消息已經(jīng)實現(xiàn)了這個行為)。
12.注釋
這是相當明顯的,但糟糕的注釋寫得很糟糕。
另一方面,我嘗試像在實際項目中那樣注釋良好的代碼。盡管我不是喜歡每一行都注釋的開發(fā)者,但我仍然認為至少對每個函數(shù)和復(fù)雜函數(shù)中的主要步驟進行注釋是重要的。
舉個例子:
// Split each line in a goroutine
for _, line := range in {
go mapLine(line, ch)
}
msg := Message{}
// Gather the goroutine results
for range in {
// ...
}
除了函數(shù)注釋之外,一個具體的例子也可能非常有用:
// Parse a line by returning the header (token name) and the value.
// Example: -COMMENT TEST must returns COMMENT and TEST (in byte slices)
func parseLine(in []byte) ([]byte, []byte) {
// ...
}
這樣具體的例子可以幫助其他開發(fā)人員更好地理解現(xiàn)有項目。
最后但同樣重要的是,根據(jù) Go 的最佳實踐,包本身也應(yīng)進行注釋。
/*
Package good is a library for parsing the ADEXP messages.
An intermediate format Message is built by the parser.
*/
package good
13.日志記錄
另一個顯而易見的例子是糟糕代碼中缺乏生成的日志。因為我不是標準日志包的粉絲,所以在這個項目中我使用了一個名為 logrus 的外部庫。
14.go fmt
Go 提供了一套強大的工具,比如 go fmt。不幸的是,我們忘記在糟糕的代碼上應(yīng)用它,而在良好的代碼上已經(jīng)做了。
15.DDD
領(lǐng)域驅(qū)動設(shè)計(DDD)引入了普遍語言的概念,強調(diào)了在整個項目參與者(業(yè)務(wù)專家、開發(fā)人員、測試人員等)之間使用共享語言的重要性。在這個例子中無法真正衡量這一點,但保持像 Message 這樣的簡單結(jié)構(gòu)符合領(lǐng)域邊界內(nèi)部使用的語言也是提高整體項目可維護性的一個好方法。
16.性能結(jié)果
在 i7–7700 4x 3.60Ghz 上,我進行了基準測試來比較兩個解析器:
- 糟糕的實現(xiàn):60430 納秒/操作
- 良好的實現(xiàn):45996 納秒/操作
糟糕的代碼比良好的代碼慢了超過30%。
結(jié)論
在我看來,很難給出糟糕代碼和良好代碼的一般定義。在一個上下文中的代碼可能被認為是好的,而在另一個上下文中可能被認為是糟糕的。
良好代碼的第一個明顯特征是根據(jù)給定的功能需求提供正確的解決方案。如果代碼不符合需求,即使它很高效,也是相當無用的。
同時,對于開發(fā)人員來說,關(guān)心簡單、易維護和高效的代碼也很重要。
性能改進并非憑空而來,它伴隨著代碼復(fù)雜性的增加。
一個優(yōu)秀的開發(fā)人員是能夠在特定的上下文中找到這些特性之間的平衡的人。
就像在 DDD 中一樣,上下文是關(guān)鍵的。