深入 Go Playground Web服務器內幕
簡介
2010年9月,我們介紹了Go Playground,這是一個完全由Go代碼組成和返回程序運行結果的web服務器。
如果你是一位Go程序員,那你很可能已經通過閱讀Go教程或執行Go文檔中的示例程序的途徑使用過Go Playground了。
你也可以通過點擊 talks.golang.org上幻燈片中的“Run” 按鈕或某個博客上的程序(比如最近一篇關于字符串的blog)而使用之.
本文我們將學習Go playground是如何實現并與其它服務整合的。其實現涉及到不同的操作系統和運行時間,這里我們假設大家用來編寫Go的系統都基本相同。
概覽

playground服務有三部分:
- 一個運行于Google服務之上的后端。它接收RPC請求,使用gc工具編譯用戶程序,執行,并將程序的輸出(或編譯錯誤)作為RPC響應返回。
- 一個運行在GAE上的前端。它接收來自客戶端的HTTP請求并生成相應的RPC請求到后端。它也做一些緩存。
- 一個JavaScript客戶端實現的用戶界面,并生成到前端的HTTP請求。
后端
后端程序本身很簡單,所以這里我們不討論它的實現。有趣的部分是我們如何在一個安全環境下安全地執行任意用戶代碼,于此同時還提供如時間、網絡及文件系統等的核心功能。
為從Google的基礎設施隔離用戶程序,后端將它們運行在原生客戶端(或“NaCl”)中,原生客戶端(NaCl)—一個Google開發的技術,允許x86程序在Web瀏覽器中安全執行。后端使用一個能生成NaCl可執行文件的特殊版gc工具。
(這個特殊的工具將合并到Go 1.3中。想了解更多,閱讀設計文檔。如果你想提前體驗NaCl,你可以檢出一個包含所有變更的分支。)
本地客戶端會限制程序占用CPU和RAM的使用量,此外還會阻止程序訪問網絡和文件系統。然而這會導致一個問題,Go程序的許多關鍵優勢,比如并發和網絡訪問。此外訪問文件系統,對于許多程序也是至關重要的。我們需要時間功能,才展現高效的并發性能。顯然我們需要網絡和文件系統,才能顯示出來訪問網絡和文件系統方面的優勢。
盡管現在這些功能都被支持了,但是2010年發布的第一版playground時,沒有一項被支持的。當前時間功能是在2009年11月10的被支持的,可是time.Sleep卻不能使用,而且多數與系統和網絡有關的包都不被支持的
一年后,我們在playground上面實現了一個偽時間,這才使得程序可以有個正確的休眠行為。較新的playground更新引入了偽網絡和偽文件系統,這使得playground的工具鏈與正常的Go工具鏈相同。這些新引入的功能會在下面具體闡述。
偽時間
playground里面的程序可用CPU時間和內存都是有限的。除此以外程序實際使用時間也是有限制的。這是因為每個運行在playground的程序都消耗著后臺資源,以及占據客戶端和后臺間的基礎設施。限制每個程序的運行時間讓我們的維護更加可遇見,而且可以保護我們免受拒絕服務攻擊。
但是當程序使用時間功能函數的時候,這些限制將變得非常不合適。在Go Concurrency Patterns 講話中通過一個例子來演示這個糟糕的問題。這是一個使用時間功能函數比如time.Sleep和time.After的例子程序,當運行在早期的playground中時,這些程序的休眠會失效而且行為很奇怪(有時甚至出現錯誤)
通過使用一個高明的小把戲,我們可以使得Go程序認為它是在休眠,而實際上這個休眠沒有花費任何時間。在介紹這個小把戲之前,我們需要了解調度程序是管理goroutine的休眠的原理。
當一個goroutine調用time.Sleep(或者其他相似函數),調度器會在掛起的計時器堆中添加中增加一個計時器,并讓goroutine休眠。在這期間,一個特殊的goroutine計算器管理著這個堆。當這個特殊的goroutine計算器開始工作時,首先,它告訴調度器,當堆中的下一個掛起的計時器準備計時的時候喚醒自己,然后它自己就開始休眠了。當這個特殊計時器被喚醒后首先是檢測是否有計時器超時了,如果有那么就喚醒相應的goroutine,然后又回到休眠狀態。
明白了這個原理后,那個小把戲只是改變喚醒goroutine的計時器的條件。調度器并不是經過一段時間后進行喚醒,而且僅僅等待一個所有goroutines 都阻塞的死鎖產生后就進行喚醒。
playground運行時版本中維護著一個內部時鐘。當修改后的調度器檢測到一個死鎖,那么它將檢查是否有一些掛起的計時器。如果有的話,它會將內部時鐘的時間調整到最早計時器的促發時間,然后喚醒goroutine計時器。這樣一直循環往復,程序都認為時間過去了,而實際上休眠幾乎沒有耗時。
這些調度器的改變細節詳見 proc.c 和 time.goc。
偽時間解決了后臺資源耗盡的問題,但是程序的輸出該怎么辦呢?看見一個在休眠的程序,卻幾乎不耗時地正確完成工作了,這是得多么的奇怪啊!
下面的程序每秒輸出當前時間,然后三秒后退出.試著運行一下。
func main() { stop := time.After(3 * time.Second) tick := time.NewTicker(1 * time.Second) defer tick.Stop() for { select { case <-tick.C: fmt.Println(time.Now()) case <-stop: return } } }
這是如何做到的? 這其實是后臺、前端和客戶端合作的結果。
我們捕獲到每次向標準輸出和標準錯誤輸出的時間,并把這個時間提供給客戶端。那么客戶端就可以以正確的時間間隔輸出,以至于這個輸出就像是本地程序輸出的一樣。
playground的運行環境包提供了一個在每個寫入數據之前引入一個小“回放頭”的特殊寫函數,它。回放頭中包含一個邏輯字符,當前時間,要寫入數據長度。一個寫操作的回放頭結構如下:
0 0 P B <8-byte time> <4-byte data length>
這個程序的原始輸出類似這樣:
\x00\x00PB\x11\x74\xef\xed\xe6\xb3\x2a\x00\x00\x00\x00\x1e2009-11-10 23:00:01 +0000 UTC \x00\x00PB\x11\x74\xef\xee\x22\x4d\xf4\x00\x00\x00\x00\x1e2009-11-10 23:00:02 +0000 UTC \x00\x00PB\x11\x74\xef\xee\x5d\xe8\xbe\x00\x00\x00\x00\x1e2009-11-10 23:00:03 +0000 UTC
前端將這些輸出解析為一系列事件并返回給客戶端一個事件列表的JSON對象:
{ "Errors": "", "Events": [ { "Delay": 1000000000, "Message": "2009-11-10 23:00:01 +0000 UTC\n" }, { "Delay": 1000000000, "Message": "2009-11-10 23:00:02 +0000 UTC\n" }, { "Delay": 1000000000, "Message": "2009-11-10 23:00:03 +0000 UTC\n" } ] }
JavaScript客戶端(在用戶的Web瀏覽器中運行的)然后使用提供的延遲間隔回放這個事件。對用戶來說看起來程序是在實時運行。
偽文件系統
在Go本地客戶端(NaCl)的工具鏈上構建的程序,是不能訪問本地機器的文件系統的。為了解決這個問題syscall包中有個文件訪問的函數(Open, Read, Write等等)都是操作在一個內存文件系統上的。這個內存文件系統是由syscall包自身實現的。既然syscall包是一個Go代碼與操作系統內存間的一個接口,那么用戶程序會將這個偽文件系統會和一個真實的文件系統一個樣看待。
下面的示例程序將數據寫入一個文件,讓后復制內容到標準輸出。試著運行一下(你也可以進行編輯)
func main() { const filename = "/tmp/file.txt" err := ioutil.WriteFile(filename, []byte("Hello, file system\n"), 0644) if err != nil { log.Fatal(err) } b, err := ioutil.ReadFile(filename) if err != nil { log.Fatal(err) } fmt.Printf("%s", b) }當一個進程開始,這個偽文件系統加入/dev目錄下的設備和一個/tmp空目錄。那么程序可以對這個文件系統和平常一樣進行操作,但是進程退出后,所有對文件系統的改變將會丟失
在初始化的時候,可以上傳zip壓縮文件(詳見unzip_nacl.go)迄今為止只會在進行標準庫測試的時候,我們會使用解壓縮工具來提供測試數據文件。可是我們打算playground程序可以運行文檔示例、博客帖子和Golang的教程里面的數據。
具體實現詳見 fs_nacl.go 和 fd_nacl.go文件(由于是_nacl的后綴,所以只有當GOOS被設置為nacl時候,這些文件才會被加入到syscall包中)。
這個偽文件系統由fsys struct代表。其中一個全局實例(稱為fs)在初始化的時候被創建。各種和文件有關的函數都操作在fs上,而不是進行真實的系統調用。例如,這里有個syscall.Open函數:
func Open(path string, openmode int, perm uint32) (fd int, err error) { fs.mu.Lock() defer fs.mu.Unlock() f, err := fs.open(path, openmode, perm&0777|S_IFREG) if err != nil { return -1, err } return newFD(f), nil }文件描述符被一個稱為files的全局片段記錄著。每個文件描述符對應著一個file,而且每個file都會提供一 fileImpl接口的實現。這里有幾個接口的實現:
- fsysFile代表常規文件和設備 (such as/dev/random) ,
- 標準輸入輸出和標準錯誤都是naclFile的實例,這可以使用系統調用來操作真實文件(這是playground中的程序唯一訪問外部環境的途徑,
- 網絡套接字有著自己的實現,下面章節中會討論.
偽網絡訪問
和文件系統一樣,playground的網絡堆棧是由syscall包在進程內部模擬出來的,這可以讓playground項目使用回送地址(127.0.0.1)。但不能請求其他主機。
運行下面可執行的實例代碼。這個程序首先會監聽TCP的端口,接著等待連接的到來,然后將連接傳來的數據復制到標準輸出,最后程序退出。在另外一個goroutine中,他會連接那個監聽中的端口,然后向連接里面寫入數據,最后關閉。
func main() { l, err := net.Listen("tcp", "127.0.0.1:4000") if err != nil { log.Fatal(err) } defer l.Close() go dial() c, err := l.Accept() if err != nil { log.Fatal(err) } defer c.Close() io.Copy(os.Stdout, c) } func dial() { c, err := net.Dial("tcp", "127.0.0.1:4000") if err != nil { log.Fatal(err) } defer c.Close() c.Write([]byte("Hello, network\n")) }
網絡的接口比文件要復雜的多,所以偽網絡的接口的實現會比偽文件系統的要龐大和復雜的多。偽網絡必須模擬讀和寫的超時,以及處理不同地址類型和協議等等。
具體實現詳見net_nacl.go。推薦從netFile開始閱讀,因為這是網絡套接字對于fileImpl接口的實現。
前端
playground的前端是另外一個簡單的程序 (不到100行). 它的主要功能是接受客戶端的HTTP請求,然后向后臺發出對應的RPC請求,同時還會完成一些緩存工作。
前端提供一個HTTP處理程序,詳見http://golang.org/compile。這個處理程序接受帶有body標簽(其中包含要運行的Go程序代碼)和一個可選version標簽(多數客戶端應該是‘2’)的POST請求。
當前端收到一個HTTP編譯請求的時候,它首先查看緩存,檢查之前是否有過同樣的編譯請求。如果發現存在同,那么就會將緩存的響應直接返回。緩存可以防止像Go主頁上那樣的大眾化程序讓后臺過載。如果發現該請求之前沒有被緩存過,那么前端會向后臺發出相應的RPC請求,然后緩存后臺的響應,接著分析對應的事件回放(詳見偽時間),最后通過HTTP響應將JSON格式的對象返回到客戶端(像上面描述那樣)。
客戶端
各種使用playground的站點,共享著一些同樣的Javascript代碼來搭建用戶訪問接口(代碼窗口和輸出窗口,運行按鈕等等),通過這些接口來后playground前端交互。
具體實現在go.tool資源庫的playground.js文件中,可以通過go.tools/godoc/static包來導入。 其中一些代碼較為簡潔,也有一些比較繁雜, 因為這是由幾個不同的客戶端代碼合并出來的。
playground函數使用一些HTML元素,然后構成一個交互式的playground窗口小部件。如果你想將playground添加到你的站點的話,你就可以使用這些函數。
Transport接口 (非正式的定義, 是JavaScript腳本)的設計是依據網站前端交互方式提。 HTTPTransport 是一個Transport的實現,可以發送如前描述的以HTTP為基礎的協議。 SocketTransport 是另外一個實現,發送WebSocket (詳見下面的'Playing offline')。
為了遵守同源策略,各種網站服務器(例如godoc)通過playground在http://golang.org/compile下的服務來完成代理請求。這個代理是通過共有的go.tools/playground包來完成的。
離線運行
不管是Go Tour還是Present Tool都可以離線運行。 這樣的離線功能對于訪問網絡有限制的人們來說,實在太棒了。
為了離線運行,這些工具在本地運行一個特殊版本的playground后端。這個特殊的后端使用的是常規GO
工具,這些工具沒有上面提到的那些修改,而且使用WebSocker來與客戶端進行通信。
WebSocket的后端實現詳見go.tools/playground/socket包。在Inside Present講話中討論了代碼細節。
其他客戶端
playground服務不單單只有為了給Go項目官方使用 (Go by Example 是另外一個例子) 。我們很高興你能在你的站點使用該服務。我們唯一的要求就是您事先和我們聯系,在您的請求中使用唯一用戶代理(這樣我們可以確認您的身份),此外您提供的服務是有益于Go社區的。
結束語
不論是godoc,是tour,還是這樣的blog,playground已經成為Go文檔系列中不可或缺的一部分了。隨著最近的偽文件系統和偽網絡堆棧的引入,我們將激動地完善我們的學習資料來覆蓋這些新內容。
但是,最后,playground只是冰山一角,隨著本地客戶端(Native Client)將要支持Go1.3,我們期盼著社區做出更棒的功能。
這篇文章是12月12號的Go Advent Calendar中的一篇,Go AdventCalendar是一系列的博客帖子集合。
作者 Andrew Gerrand