預(yù)計閱讀時間: 23分鐘 建議收藏后閱讀
近來,經(jīng)常有人問我在Go 語言的項目里,什么樣的代碼算好代碼,什么樣的代碼算壞代碼。
我發(fā)現(xiàn)這個練習(xí)很有趣。嗯,這個項目太有趣了,所以我決定寫一篇博客和大家分享一下。
為了更好的和大家說明我的觀點,我利用一個我工作中碰到的問題為例,給大家講講。這個項目是一個空中流量控制系統(tǒng)(ATM)。
代碼我放在Github[1]上了。
背景
首先,我簡單介紹一下這個項目。
Eurocontrol
是歐洲國家管理空中飛機流量的一個機構(gòu)。通常在Eurocontrol
和 Air Navigation Service Provider (ANSP)
直接交換數(shù)據(jù)的網(wǎng)絡(luò)是AFTN
。這個網(wǎng)絡(luò)主要用來交換兩種不同的消息類型:ADEXP
和 ICAO
。
其實我也不知道這兩類信息有啥區(qū)別,不過我們當(dāng)成兩類不同的信息就好了。
每種消息類型有它自己的格式,不過兩者傳達的信息都是等價的(或多或少)。
我們這個項目最關(guān)鍵的是性能。
這個項目會提供兩種解析ADEXP
的Go 語言實現(xiàn):
我們在這個練習(xí)里,只處理其中的一小部分。
我們的主要目的是為了說明問題,不是證明我有多強,對吧?
解析
簡單來說,ADEXP
消息里包含了一些指令,或者說關(guān)鍵字。比如:
-ARCID ACA878
ARCID
的意思是飛行器(A
iR
craft ID
entifier)的識別碼是ACA878
。
-EETFIR EHAA 0853
-EETFIR EBBU 0908
這行的意思是FIR
(F
light I
nformation R
egion)。
第一行FIR
是EHAA 0853
, 第二行的是EBBU 0908
.
-GEO -GEOID GEO01 -LATTD 490000N -LONGTD 0500000W
-GEO -GEOID GEO02 -LATTD 500000N -LONGTD 0400000W
這里面的指令代碼是GEO
,GEOID
, LATTD
, LONGTD
。
為了提高處理的效率,我們自然希望通過并發(fā)的方式來處理這些連續(xù)的數(shù)據(jù)。
goroutine
去分割每一行。每個goroutine
處理一行,然后返回結(jié)果。ADEXP
或ICAO
.每個包包含一個名為adexp.go
的文件。這個文件會把主要的函數(shù)ParseAdexpMessage()
暴露出來。
咱們下面一步步來比較一下
讓我們現(xiàn)在一步步的來實現(xiàn)這個算法。通過兩種方式,我們可以比較一下那種寫法更好,那種寫法比較差,以及為什么。
比較差的寫法是通過字符串輸入來處理。因為Go提供了強大的字節(jié)Bytes
操作(如trim
、regexp
等),我們可以把AFTN
消息當(dāng)成通過TCP
獲取到的消息,所以我們沒有理由把它轉(zhuǎn)換成字符串輸入。
譯者注:直接用byte處理會速度快一些
在差的實現(xiàn)的這個包里的代碼實現(xiàn)是非常糟糕的。(說的非常好,不然呢)。在第二個參數(shù)里返回的一些潛在的錯誤甚至沒有得到處理。
preprocessed, _ := preprocess(string)
好的代碼會處理各種潛在的錯誤:
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)該大寫,結(jié)尾也不需要標(biāo)點符號。因為這個錯誤信息只是簡單的字符串,不需要格式化,所以用errors.New()
性能更好。
敲黑板啦,所以好的實現(xiàn)應(yīng)該是這樣的:
if len(in) == 0 {
return nil, errors.New('input is empty')
}
譯者注:嵌套是初級程序員跨不去的一個坎 :)
mapLine()
函數(shù)是避免嵌套的一個很好的例子。我們先看看差的實現(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
}
}
相反,好的實現(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
}
用我的觀點來看,這樣的代碼更易讀一些。另外,錯誤處理也最好這樣實現(xiàn)。
例如:
a, err := f1()
if err == nil {
b, err := f2()
if err == nil {
return b, nil
} else {
return nil, err
}
} else {
return nil, err
}
譯者注:這可能是爛的不能再爛的代碼了
對比一些這個:
a, err := f1()
if err != nil {
return nil, err
}
b, err := f2()
if err != nil {
return nil, err
}
return b, nil
再說一遍,這樣看起來好多了。
典型的差的實現(xiàn)如下:
func preprocess(in container) (container, error) {
}
我們這個項目的背景是性能很重要。另外考慮到處理的數(shù)據(jù)可能很大,更好的選擇是傳遞container
結(jié)構(gòu)的指針。然而,在上面的例子中,是直接傳遞數(shù)據(jù)的。
因為采用了分片(一個簡單的忽略了底層數(shù)據(jù)的24字節(jié)的結(jié)構(gòu))處理,所以好的實現(xiàn)不存在這些問題。
func preprocess(in []byte) ([][]byte, error) {
}
通過傳值或傳引用的方式傳遞參數(shù)其實是一個和語言無關(guān)的事。通過傳值這種方式傳遞參數(shù)可以避免一些副作用,比如數(shù)據(jù)被修改,等等。
譯者注:如果大家對這點有點疑惑,需要去做做功課咯。傳值一般是復(fù)制一份數(shù)據(jù),傳遞到另外的函數(shù)去處理。這樣的缺點是浪費內(nèi)存,優(yōu)點是數(shù)據(jù)不會修改。傳遞指針(也就是引用的方式)優(yōu)點是節(jié)約內(nèi)存,缺點是數(shù)據(jù)可能被修改。我看很多C++程序員就是故意這樣去處理的。。。很費解。有C++程序員站出來解釋一下~
傳值還有一些優(yōu)點,比如在單元測試的時候,或者重構(gòu)并發(fā)的代碼等等(否則我們需要每次都檢查一下數(shù)據(jù)是否被修改)。
我非常相信具體在選擇傳值還是引用的問題上,一定要根據(jù)項目的背景去權(quán)衡。(誰說不是呢,外國人寫點東西廢話真不少。。。)
這個差的實現(xiàn)其實是基于一個好的想法:通過goroutine
去并行化處理數(shù)據(jù)(每個goroutine
處理一行)。
for i := 0; i < len(lines); i++ {
go mapLine(&msg, lines[i], ch)
}
這里采用了一個msg
這個共享內(nèi)存變量。
mapLine()
函數(shù)有三個參數(shù):
msg
的指針。mapLine()
處理后的數(shù)據(jù)合并到msg
變量。channel
給一個共享的Message變量發(fā)送指針違反了Go的原則:
不要通過共享內(nèi)存來通信,通過通信來共享內(nèi)存。
共享變量有兩個缺點:
因為可以并發(fā)地修改 Slice(兩個或兩個以上的go routine并發(fā)地修改),在差的實現(xiàn)中,我們不得不處理mutex。
例如,Message 包含 Estdata [] estdata。通過append 別的estdata修改slice,一定要這樣才行:
mutexEstdata.Lock()
for _, v := range value {
fl := extractFlightLevel(v[subtokenFl])
msg.Estdata = append(msg.Estdata, estdata{v[subtokenPtid], v[subtokenEto], fl})
}
mutexEstdata.Unlock()
事實上,除了一些很特殊的場景,使用在go routine 中使用 mutex 可能本身就是臭代碼。
因為潛在的假共享(一個CPU的core cache的cache line對于別的CPU core cache可能是無效的)的問題,所以通過線程或者goroutine 共享內(nèi)存不是個好主意。也就是說我們要盡可能地避免在不同的線程或goroutine間共享會被修改的變量。
雖然在這里例子中,因為輸入文件很小,所以假共享的作用看不出來。但是,以我來看,最好在開發(fā)者牢記這點。
讓我們看看好的實現(xiàn)怎么處理并行:
for _, line := range in {
go mapLine(line, ch)
}
現(xiàn)在mapLine()
有兩個輸入:
goroutine
不會修改Message的結(jié)構(gòu)。處理結(jié)果的goroutine
的是父goroutine
。
msg := Message{}
for range in {
data := <-ch
switch data.(type) {
// Modify msg variable
}
}
我認(rèn)為這樣實現(xiàn)最好,更符合Go語言僅僅通過通信去共享內(nèi)存的原則。
一個可能被吐槽的點是每行都spawn
一個goroutine
。之所以這樣實現(xiàn)是因為ADEXP 消息只包含幾千行,所以也不至于導(dǎo)致goroutine
爆發(fā)。更好的選擇是創(chuàng)建一個可復(fù)用的goroutine
池。
在差的實現(xiàn)中,就像上面描述的一樣,一旦mapLine()處理結(jié)束后,我們需要通知父goroutine
。這是通過chan string實現(xiàn)的:
ch <- 'ok'
因為父routine沒有去檢查channel返回的結(jié)果,更好的選擇可能是使用chan struct{}
, ch <- struct{}{}
或者其他更好的選擇(GC wise),比如chan interface{}
,返回ch <- nil
。
Go 允許在判斷條件前加其他語句。
如下的代碼,
f, contains := factory[string(token)]
if contains {
// Do something
}
可以簡寫一下:
if f, contains := factory[sToken]; contains {
// Do something
}
經(jīng)過重構(gòu),提高了一點可讀性,有沒有呢?
差的實現(xiàn)中,switch沒有添加處理默認(rèn)情況。
switch simpleToken.token {
case tokenTitle:
msg.Title = value
case tokenAdep:
msg.Adep = value
case tokenAltnz:
msg.Alternate = value
// Other cases
}
雖然說對于開發(fā)者來說,默認(rèn)值是可選的。不過假如有默認(rèn)值,肯定會更好:
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)
}
添加默認(rèn)值,有助于盡可能地避免開發(fā)者開發(fā)過程中產(chǎn)生的錯誤。
parseComplexLines()
函數(shù)是用來解析復(fù)雜的token。差的實現(xiàn)中使用了遞歸。
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不支持tail-call(尾調(diào)用,在函數(shù)體末尾返回另一個函數(shù)的調(diào)用)優(yōu)化子函數(shù)調(diào)用。好的實現(xià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}
}
第二種實現(xiàn)比第一種性能更好。
我們需要把ADEXP和ICAO消息分開。差的實現(xiàn)如下:
const (
AdexpType = 0 // TODO constant
IcaoType = 1
)
更優(yōu)雅的Go實現(xiàn)如下:
const (
AdexpType = iota
IcaoType
)
它們的計算結(jié)果是一樣的,下面的這種寫法可以減少潛在的程序員犯的錯。
每個解析器提供了函數(shù)來判斷消息是否是超過了upper level(至少一個點超過了level 350)。
比較差的代碼實現(xiàn)如下:
func IsUpperLevel(m Message) bool {
for _, r := range m.RoutePoints {
if r.FlightLevel > upperLevel {
return true
}
}
return false
}
這意味著我們必須把Message傳遞給函數(shù)。然而好的代碼使用Message receiver 簡化了函數(shù):
func (m *Message) IsUpperLevel() bool {
for _, r := range m.RoutePoints {
if r.FlightLevel > upperLevel {
return true
}
}
return false
}
下面的這些實現(xiàn)更受人喜歡。我們給Message struct 添加了具體的實現(xiàn)。
它可能也是使用Go interface的第一步。假如有一天我們需要創(chuàng)建具有同樣行為(IsUpperLevel())的另一個struct,甚至不需要重構(gòu)初始化的代碼(因為Message已經(jīng)implement這個實現(xiàn)了)。
還有一個很明顯的問題就是差的代碼注釋很少。
另一方面,我盡量像我在真實項目中一樣注釋“好的代碼”。即使這樣,我也不是那種會注釋每一行代碼的開發(fā)者,我仍然認(rèn)為在一個復(fù)雜的函數(shù)里,至少每個函數(shù)以及主要的步驟都應(yīng)該注釋一下。
例如:
// Split each line in a goroutine
for _, line := range in {
go mapLine(line, ch)
}
msg := Message{}
// Gather the goroutine results
for range in {
// ...
}
關(guān)于注釋一個很有說服力的例子如下:
// 解析一行并返回header(token名)和值
// 例如: -COMMENT TEST 應(yīng)該返回 COMMENT 和 TEST (in byte slices)
func parseLine(in []byte) ([]byte, []byte) {
// ...
}
這樣的代碼真的可以幫助到其他程序員更好的理解現(xiàn)存的項目
雖然最後才說,但不表示它最不重要,根據(jù)Go 的最佳實踐,package最好也需要注釋一下。
/*
Package good is a library for parsing the ADEXP messages.
An intermediate format Message is built by the parser.
*/
package good
另一個顯而易見的問題是在差的實現(xiàn)里缺少日志。因為我不喜歡Go標(biāo)準(zhǔn)庫的log package,所以我使用了Logrus[2].
Go 提供了很好的格式化工具,go fmt。很不幸,我們忘記了在差的實現(xiàn)上執(zhí)行了(譯者注:你確定不是故意的嗎?),但是我們再好的代碼上執(zhí)行了。(譯者注:好意外?。?/p>
(略)
在一臺 i7–7700 4x 3.60Ghz機子上,我分別跑了一下兩個解析器:
差的代碼比好的實現(xiàn)慢30%。
結(jié)論
對我來說,其實定義好的實現(xiàn)和差的實現(xiàn)是比較難的。離開場景談應(yīng)用都是耍流氓。
好的代碼的第一個特征是根據(jù)需求提供正確的解決方案。一個不滿足需求的代碼,哪怕性能再好,也沒什么卵用。簡單、可維護、性能好的代碼是值得開發(fā)者時刻關(guān)注的幾個點。
性能不會憑空提升,它和代碼的復(fù)雜度增加相關(guān)。
好的開發(fā)者能夠根據(jù)需求和這些特點中找到平衡。
和DDD一樣,context是關(guān)鍵?。海?/p>
同時,對于開發(fā)者來說
ADEXP 文件如下:
-TITLE IFPL
-ADEP CYYZ
-ALTNZ EASTERN :CREEK'()+,./
-ADES AFIL
-ARCID ACA878
-ARCTYP A333
-CEQPT SDE3FGHIJ3J5LM1ORVWXY
-EETFIR KZLC 0035
-EETFIR KZDV 0131
-EETFIR KZMP 0200
-EETFIR CZWG 0247
-EETFIR CZUL 0349
-EETFIR CZQX 0459
-EETFIR EGGX 0655
-EETFIR EGPX 0800
-EETFIR EGTT 0831
-EETFIR EHAA 0853
-EETFIR EBBU 0908
-EETFIR EDGG 0921
-EETFIR EDUU 0921
-ESTDATA -PTID XETBO -ETO 170302032300 -FL F390
-ESTDATA -PTID ARKIL -ETO 170302032300 -FL F390
-GEO -GEOID GEO01 -LATTD 490000N -LONGTD 0500000W
-GEO -GEOID GEO02 -LATTD 500000N -LONGTD 0400000W
-GEO -GEOID GEO04 -LATTD 520000N -LONGTD 0200000W
-BEGIN RTEPTS
-PT -PTID CYYZ -FL F000 -ETO 170301220429
-PT -PTID JOOPY -FL F390 -ETO 170302002327
-PT -PTID GEO01 -FL F390 -ETO 170302003347
-PT -PTID BLM -FL F171 -ETO 170302051642
-PT -PTID LSZH -FL F014 -ETO 170302052710
-END RTEPTS
-SPEED N0456 ARKIL
-SPEED N0457 LIZAD
-MSGTXT (ACH-BEL20B-LIML1050-EBBR-DOF/150521-14/HOC/1120F320 -18/PBN/B1 DOF/150521 REG/OODWK RVR/150 OPR/BEL ORGN/LSAZZQZG SRC/AFP RMK/AGCS EQUIPPED)
-COMMENT ???FPD.F15: N0410F300 ARLES UL153 PUNSA/N0410F300 UL153
VADEM/N0400F320 UN853 PENDU/N0400F330 UN853 IXILU/N0400F340 UN853
DIK/N0400F320 UY37 BATTY
原文鏈接:https://teivah.medium.com/good-code-vs-bad-code-in-golang-84cb3c5da49d
作者:Teiva Harsanyi