ngrok 1.X 原始碼解析(WIP)
背景
ofollow,noindex" target="_blank">ngrok 是我第一次接觸的 go 專案,也是我第一個完整閱讀過原始碼的開源專案。一開始讀程式碼我還是 go 語言零基礎,只寫過一點點 Web 後端 API,讀了好幾個月,後面還趁著做畢業設計的機會跟著重新敲了一遍,所以我從中收穫了不少東西,一直都想寫一篇文章總結一下我對這套程式碼的理解。
ngrok 的目的是將本地的埠反向代理到公網,讓你通過訪問公網某個機器,經過流量的轉發,訪問到內網的機器。這個事情你可以有不同的叫法:反向代理、埠轉發(對映)、內網穿透……原理其實不難,解決的需求也簡單。
一方面,我們個人的裝置一般都在 NAT 後面,不能被公網裝置直接訪問到,內網機器可以主動向公網發起連線,但公網不能穿過 NAT 訪問到內網機器;另一方面,因為我國特有的政策,獲得公網 IP 和域名想對而言要付出額外的成本。ngrok 就很適合臨時暴露內網服務的場景。在程式員常聚集的 V2EX,經常能看到問怎麼做內網穿透的月經貼。當然,現在大家都用frp 了,ngrok 2.0 閉源,而 frp 是中國人搞的,增加了很多新的功能,使用體驗也比 ngrok 好很多。
專案結構
來看一下 ngrok 整個專案的結構(忽略掉了一些不必要的檔案)
. ├── ... ├── assets// 存放 web 靜態檔案和 tls 檔案 │├── ... └── src └── ngrok ├── cache │└── lru.go ├── client │├── cli.go │├── config.go │├── controller.go │├── debug.go │├── main.go │├── metrics.go │├── model.go │├── mvc ││├── ... │├── release.go │├── tls.go │├── update_debug.go │├── update_release.go │└── views// view 層包括 Web 和終端 ││├── ... ├── conn// tcp 連線相關的操作:標記連線的型別(http/tcp),發起監聽(Listen)、發起主動連線(Dial),以及關鍵的交換兩個連線的資料(Join) │├── conn.go │└── tee.go ├── log// 日誌 │└── logger.go ├── main ├── msg// 訊息的序列化和反序列化 │├── conn.go │├── msg.go │└── pack.go ├── proto// 主要被 cli/model.go 呼叫 │├── http.go │├── interface.go │└── tcp.go ├── server │├── cli.go// 命令列引數的解析 │├── control.go// control 的註冊、代理連線的註冊 │├── http.go// http 的監聽和處理 │├── main.go// 程式入口:各資源池的初始化、監聽控制連線和代理連線 │├── metrics.go// 效能資料統計 │├── registry.go// tunnel/control 池的維護、tunnel/control 例項的增刪查 │├── tls.go// 讀取 tls 配置 │└── tunnel.go// tunnel 的建立和關閉、實現公有連線和代理連線之間的匹配 ├── util │├── broadcast.go// 被客戶端的 MVC 模型用來更新資料 │├── errors.go// 錯誤處理 │├── id.go// 唯一 ID 的生成 │├── ring.go// │└── shutdown.go// 通過鎖、channle、defer實現的關閉機制 └── version └── version.go// 輸出版本號
服務端程式碼
來看一下服務端 ngrokd 的關鍵程式碼:
func Main() { ... // init tunnel/control registry registryCacheFile := os.Getenv("REGISTRY_CACHE_FILE") // 初始化 tunnelRegistry 用來註冊 tunnel tunnelRegistry = NewTunnelRegistry(registryCacheSize, registryCacheFile) // 初始化 controlRegistry 用來註冊多個客戶端 controlRegistry = NewControlRegistry() // 初始化一個監聽池,保證可以通過協議名來找到對應的監聽 listeners = make(map[string]*conn.Listener) ... // 監聽來自公網的http請求。https 和 http 的邏輯是類似的,只是多了一個載入 tls 配置的步驟,這裡就先省略 https 的部分。 if opts.httpAddr != "" { listeners["http"] = startHttpListener(opts.httpAddr, nil) } ... // 監聽控制連線和建立代理連線的請求 tunnelListener(opts.tunnelAddr, tlsConfig) }
在這裡一個 control 對應一個 ngrok 客戶端,這個客戶端可能處於不同的 NAT,會主動連線 tunnelAddr 埠,向服務端建立控制連線,註冊自己,並保持心跳。通過控制連線兩端會互相傳送一些控制資訊,比如說開始新的代理、關閉連線、認證等等。
tunnel 維護邏輯上的埠對映,分為兩種:tcp 和 http/https。一個 tcp
例子是:服務端埠--tunnelAddr<--客戶端-->內網目的埠
,http 例子:服務端http埠(一般是80)--tunnelAddr<--客戶端-->內網http服務
。tunnel 記錄了必要的資訊,保證你從公網訪問這個服務端埠
/服務端http埠
的時候,ngrokd 能夠找到對應的客戶端和內網真正的被代理埠。(這裡的箭頭表明實際連線發起的方向,可以看到所有的連線,不管對內還是對外,都是客戶端發起的)
ngrokd 啟動的時候,會暴露三個埠,一個(httpAddr)用來監聽 http 連線,一個(httspAddr)監聽 https 連線,最後一個(tunnelAddr)用來監聽兩類連線:控制連線和代理連線。
我們分三個階段來分析 ngrokd 做的工作:
- 註冊階段
- 建立代理連線階段
- 轉發階段
註冊階段
在這個tunnelListener()
函式裡,其實根據控制資訊的不同做了分流,分別是註冊階段
(NewControl()
)和建立代理連線階段
(NewProxy()
)的入口:
func tunnelListener(addr string, tlsConfig *tls.Config) { // 這裡監聽 tunnelAddr,進來的連線會被打上 tun 的標記,然後放到 listener.Conns 的 channel 裡 listener, err := conn.Listen(addr, "tun", tlsConfig) if err != nil { panic(err) } ... // 而在這裡則從上述的 listener.Conns 裡取出連線,針對每個連線起一個 goroutine 併發地處理 for c := range listener.Conns { go func(tunnelConn conn.Conn) { ... // 從連線裡讀取控制資訊 var rawMsg msg.Message if rawMsg, err = msg.ReadMsg(tunnelConn); err != nil { tunnelConn.Warn("Failed to read message: %v", err) tunnelConn.Close() return } ... // 根據不同的控制資訊做不同的操作 switch m := rawMsg.(type) { // 註冊一個新的 control(進入註冊階段) case *msg.Auth: NewControl(tunnelConn, m) // 請求建立新的代理連線(進入建立代理連線階段),後面再解釋 case *msg.RegProxy: NewProxy(tunnelConn, m) default: tunnelConn.Close() } }(c) } }
NewContorl() 做了什麼
NewContorl(ctlConn conn.Conn, authMsg *msg.Auth)
做了這麼一些事情:
- 認證
-
建立
control
例項c
,上面的引數ctlConn
會被作為c
的成員c.conn
,control
例項註冊到controlRegistry
-
啟動傳送訊息的 goroutine,用來把從
c.out
讀到的訊息通過c.conn
發給客戶端:go c.writer()
-
要求客戶端發起代理連線,將控制訊息寫到
c.out
裡,這時候c.writer()
就會非同步地讀取c.out
裡的資訊c.out <- &msg.ReqProxy{}
-
啟動三個 goroutine:
c.reader()
負責從控制連線裡讀資訊並寫到c.in
;c.manager()
負責從c.in
裡讀訊息並做對應的操作;c.stopper()
等待停止的訊號,負責回收所有資源,包括把control
例項從conrtorl
池移除:controlRegistry.Del(c.id)
等go c.manager() go c.reader() go c.stopper()
goroutine 和 chan 的配合
這裡的 goroutine 和 chan 的用法很經典。control
例項裡有兩個chan
,分別是in
和out
,用來達成不同 goroutine 之間的通訊。
比如上面提到的“要求客戶端發起代理連線”的操作,訊息的傳遞路徑是這樣的:
在NewContorl()
goroutine 內,msg.ReqProxy{}
被塞到c.out
這個 chan 裡:
c.out <- &msg.ReqProxy{}
在c.writer()
goroutine 內,不斷從c.out
讀,然後把讀到的資訊m
寫到c.conn
(也就是該control
對應的控制連線)裡:
for m := range c.out { c.conn.SetWriteDeadline(time.Now().Add(controlWriteTimeout)) if err := msg.WriteMsg(c.conn, m); err != nil { panic(err) } }
而讀取訊息的路徑是c.conn-->c.read()-->c.in-->c.manager()
。各個 goroutine 都是併發、非同步、解耦的,中間通過 channel 串聯起來,十分優雅。在各個 chan 沒有訊息的時候,range 操作是阻塞著的,但因為 goroutine 是非同步的,所以不會影響到其他的 goroutine,go 的 runtime 會幫你做好各個執行流的排程。
還值得一提的是它的停止機制。c.stopper()
利用了 chan 的一個特性:一旦 chan 被關閉,range chan 的操作就會退出。如果我們把每個 goroutine “繫結”的 chan 都關閉了,實際上就解除了range channel
的阻塞和迴圈狀態,相當於關閉了對應的 goroutine。
// range c.out 的三種狀態 for m := range c.out{// 1. c.out 為空:阻塞 ...// 2. 從 c.out 讀到 m:執行大括號裡的邏輯 } // 其他程式碼 ...// 3. c.out 被關閉,繼續執行下面的程式碼,直到該函式結束
所以c.stopper()
一旦接受到 stop 的指令,就會把所有相關的chan
(c.in
c.out
)關閉,則c.read()
c.manager()
c.writer()
等goroutine
也就都執行完畢了,從而達到回收chan
和goroutine
資源的目的。
c.manager() 做了什麼
兩件事:
- 和客戶端維持心跳
- 註冊 tunnel
func (c *Control) manager() { ... // 例項化一個計時器,每10秒傳送一次訊息到 reap.C reap := time.NewTicker(connReapInterval) defer reap.Stop() for { select { // 每10秒 reap.C 就會有內容到達,這裡檢查客戶端傳送過來的心跳包,如果大於30秒,就啟動停止流程 case <-reap.C: if time.Since(c.lastPing) > pingTimeoutInterval { c.conn.Info("Lost heartbeat") c.shutdown.Begin() } // 從 c.in 讀取訊息 case mRaw, ok := <-c.in: // c.in 若被關閉,ok 的值是 false,直接結束該函式(gorotine) if !ok { return } // 根據控制訊息的型別,作對應操作 switch m := mRaw.(type) { // 註冊 tunnel case *msg.ReqTunnel: c.registerTunnel(m) // 傳送心跳包(報告當前時間) case *msg.Ping: c.lastPing = time.Now() c.out <- &msg.Pong{} } } } }
go 標準庫裡的計時器也是很典型的 channel 的應用。
至此,註冊階段就完畢了。服務端通過客戶端的主動連線,知道了客戶端的存在,為它註冊了一系列資源,向客戶端請求了一條代理連線,獲知了客戶端想要代理的服務資訊(tunnel),並通過心跳包保持了聯絡。