這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
這個題目的原文叫做《Gobs on the wire》,作者巧妙的用了“gob”這個詞。gob本來是Golang的一個關於網路通訊協定的包。而在這裡,我感覺標題也可以翻譯為《關於線上的那一大陀……》。好吧,我得承認,這麼翻譯實在不雅。
————翻譯分割線————
飛翔的 gob
這周,我想跟大家談談如何用 Go 編寫基於同步請求和非同步事件通知的 Client/Server 系統。
為了協助學習 Go,我複製了一個Conserver 命令列服務。當然,現在對於世界來說沒必要有另外一個命令列服務。然而由於Go語言帶給開發人員的特性,非常適合用來做命令列服務,所以這將會是一次非常有趣的體驗。命令列服務的任務是從一個或者多個串口上匯總輸出(或者說各種系統上的一個或者多個實現了使用TCP串連的Rtelnet協議的終端服務)。 它將輸出記錄到檔案,同時讓輸出可以被別人即時的看到。它僅允許唯一一個使用者進行讀寫,用於實際控制裝置。
Go版本的命令列服務被稱作gocons。先去看看,然後回到這裡。跟以往一樣,我先說說在編寫這個的時候,我學到了Go的哪些東西。
最初,我嘗試用netchan來構建這個。我想,如果用戶端在某個通道(譯註:channel,下同)上向伺服器請求,當在某個命令列上有事件發生時接收通知這會是一件很酷的事情。但是,由於netchan不能處理多個通道,它徹底的崩潰了。 我認為,理論上是可能通過某種方法讓netchan處理多個通道,但是這看起來確實非常難, 而且多個通道可能永遠不同步。所以,我轉向了正確的方向……
接下來是想使用rpc包實現用戶端和伺服器之間的協議。但是,這帶來了另外一個問題:RPC,它的定義是同步的。它沒有明確定義服務端如何使用RPC包下發非同步事件到用戶端,如“這些位元組已經到達”。我需要自己編寫協議,通過兩個步驟,一個同步調用,例如“我可以收聽這個嗎?是的。”以及非同步事件“你監視的命令列剛剛輸出了這些位元組”。 我之前已經編寫了這些協議,這並不難,只是有一些繁瑣。而Go提供的gob包就是你開發一個協議所需要的全部。
而你需要做的就是描繪出協議訊息,並且讓gob負責處理打包和解包,就是計算訊息的分隔。在我們的例子中,這個類型被稱為connReq和connReply。在理想世界裡,這些應當是某個庫的公用類型,用戶端和伺服器端同時使用它們。在gocons中,我發懶了,只是複製並粘貼了它們。用戶端在net.TCPConn上有gob.Decode,而結果就是connReq(如果不是的話,那說明有什麼不對勁,用戶端可以幹掉串連或解碼這個串連上的後續內容)。由於Go沒有union(非型別安全的),connReq和connReply即便是給定的協議訊息不使用的情況下,也應當包含所有的欄位。我並沒有仔細思考這個協議, 但由於未使用欄位可能是位元組段或字串,不可能有很多;而且空的位元組段將被編碼為nil,而不是用零填充的整個位元組緩衝區。
對此更加精緻的設想是構造一個層次化的類型,最簡單的類型(僅有一個int指名類型)作為基礎,後面跟一個複雜的類型。但是要弄清楚給gob.Decode的是什麼類型是非常難的;這就像你必須將協議拆分為兩部分,並且分別對其調用go.Encode。第一個可以告訴這是什麼類型,而第二個gob可以是包含資料的短語。無論如何,我不會在gocons裡這麼做。簡單就好!
在伺服器中,有兩個程式碼片段很有趣。一個是使用JSON作為設定檔的格式。另一個是如何將新的資料發送到所有收聽者。
第一個比較簡單。僅僅是示範在不使用json.Unmarshall的情況下,如何從JSON檔案中擷取資料。我搞不清楚json.Unmarshall,所以在我嘗試不用它的情況下,讓json.Decode工作。我並不是說這麼做好,但是它能運行,這可能可以協助其他在Go中讀取JSON的正在尋找例子的人。
希望的輸入是這樣的:
{ "consoles": { "firewall": "ts.company.com:2070", "web01": "ts.company.com:2071", "web02": "ts.company.com:2072", "web03": "ts.company.com:2073" }}
目標是在consoles中對應的每個索引值調用addConsole。
是這樣做的,如果你不希望這樣(或者知道如何做的話)使用json.Unmarshal吧:
r, err := os.Open(*config, os.O_RDONLY, 0) if err != nil { log.Exitf("Cannot read config file %v: %v", *config, err) } dec := json.NewDecoder(r) var conf interface{} err = dec.Decode(&conf) if err != nil { log.Exit("JSON decode: ", err) } hash, ok := conf.(map[string]interface{}) if !ok { log.Exit("JSON format error: got %T", conf) } consoles, ok := hash["consoles"] if !ok { log.Exit("JSON format error: key consoles not found") } c2, ok := consoles.(map[string]interface{}) if !ok { log.Exitf("JSON format error: consoles key wrong type, %T", consoles) } for k, v := range c2 { s, ok := v.(string) if ok { addConsole(k, s) } else { log.Exit("Dial string for console %v is not a string.", k) } }
這裡的模式大致如此,json.Decode提供了一個interface{},然後根據結構使用類型選取器,然後獲得你期望的在那裡獲得的內容。
更加簡單的辦法是使用json.Unmarshal。從文檔中很難理解如何使用,幸好這篇文章讓它看起來更加清晰。
伺服器是在一個迴圈裡處理i/o的一系列的goroutine組成。每個它監視的命令列擁有一個讀goroutine和一個寫goroutine。讀的從其中擷取位元組,然後向所有收聽的gocons用戶端分發。它管理了一個包含用戶端列表的鏈表,不過另外一種資料結構可能會工作得更好。不論是在net.TCPConn還是通道,用戶端都是沒有排序的。等待新資料的通道就像是用戶端的代理goroutine。當每個用戶端串連時,會建立一對goroutine,一個用於讀,一個用於寫。這允許我們在輸入上實現阻塞讀(例子可參看dec.Decode),而不用擔心阻塞伺服器上的其他任務。
利用一個獨立的goroutine保證負責向TCP串連寫入,這樣可以不使用任何鎖。作為練習,可以讓多個命令列管理器同時說:“我有一些資料需要TCP串連的多工!”而無須關心它們向串連寫入資料時相互幹擾。(當前的實現一次只可以監聽一個命令列。)
下面的片段示範了當有新的內容時,如何打包並且向所有命令列觀看者發送通知:
select { // a bunch of other channels to monitor here... case data := <-m.dataCh: if closed(m.dataCh) { break L } // multicast the data to the listeners for l := listeners; l != nil; l = l.next { if closed(l.ch) { // TODO: need to remove this node from the list, not just mark nil l.ch = nil log.Print("Marking listener ", l, " no longer active.") } if l.ch != nil { ok := l.ch <- consoleEvent{data} if !ok { log.Print("Listener ", l, " lost an event.") } } }
這樣,我們建立了一個新的consoleEvent,並且將其發送給了每個收聽者。這有點浪費:它產生了許多垃圾,這意味著記憶體回收行程需要更加努力的工作。可以建立一個consoleEvent,然後向所有的收聽者發送這一個。但是,如果這樣共用記憶體,需要開發人員決定是讓共用記憶體唯讀,還是使用互斥控制它的訪問。在我們的例子中,使用了唯讀方式,像這樣:
// new event arrived from the console manager, so send it down the TCP connection case ev := <-evCh: reply.code = Data reply.data = ev.data err := enc.Encode(reply) if err != nil { log.Print("connection ", c.rw, ", failed to send data: ", err) break L }
在這個模式下,兩個goroutine只負責讀取和寫入,這就像魔法。這夢幻般的降低了實現gocons需要的代碼量。原先的命令列服務需要數百行複雜的代碼,關於設定select掩碼,等待select,檢測fd是否需要accept()或者read()或者其他(同時要找到讓fd可用的正確的資料結構)。在gocons中,以及其他Go程式,如http包實現的http服務,可以使用阻塞的讀,讓Go安排運行使得整個系統並未阻塞。
然而,如果考慮當向用戶端TCP串連寫入發生阻塞時的情況,會很有趣。當系統在寫時發生阻塞,它最終會放棄,並且阻止從命令列上讀取,阻塞其他所有用戶端。為了對應這種情況,你需要在適當的地方建立防火牆:共用的資源不應當讓個體阻塞它們。你需要在用戶端代理goroutine和命令列管理者讀取goroutine之間的通道設定一個隊列,讓其在通道上實現非阻塞寫,並且當一個阻塞了,就處理它。例如,可以關閉通道,然後說“嘿,你的排水不暢,應該清理一下,然後再來找我。”
用Go編寫並且調試這個伺服器,讓我學到了許多東西。而我仍然還有許多東西需要學習:代碼中仍然有一些神秘的東西,例如為什麼我需要runtime.Gosched()保證不會阻塞,以及如何處理關閉的通道在select中帶來的麻煩。還有更多的隱藏在setOwner中的神秘工作,首要的是:如何發現將從一個地方轉寄到另一個地方的“pump goroutine”中的bug(在Go運行時環境,或者我能理解的情況下)