Go 語言的網路程式設計簡介

貓冬發表於2017-04-29

本文通過 Go 語言寫幾個簡單的通訊示例,從 TCP 伺服器過渡到 HTTP 開發,從而簡單介紹 net 包的運用。

TCP 伺服器

首先來看一個 TCP 伺服器例子

package main

import (
    "fmt"
    "log"
    "net"
)

func main() {
    // net 包提供方便的工具用於 network I/O 開發,包括TCP/IP, UDP 協議等。
    // Listen 函式會監聽來自 8080 埠的連線,返回一個 net.Listener 物件。
    li, err := net.Listen("tcp", ":8080")
    // 錯誤處理
    if err != nil {
        log.Panic(err)
    }
    // 釋放連線,通過 defer 關鍵字可以讓連線在函式結束前進行釋放
    // 這樣可以不關心釋放資源的語句位置,增加程式碼可讀性
    defer li.Close()

    // 不斷迴圈,不斷接收來自客戶端的請求
    for {
        // Accept 函式會阻塞程式,直到接收到來自埠的連線
        // 每接收到一個連結,就會返回一個 net.Conn 物件表示這個連線
        conn, err := li.Accept()

        if err != nil {
            log.Println(err)
        }
        // 字串寫入到客戶端
        fmt.Fprintln(conn, "Hello from TCP server")

        conn.Close()
    }
}

在對應的資料夾下啟動伺服器

$ go run main.go

模擬客戶端程式發出請求,這裡使用 netcat 工具,也就是 nc 命令。

$ nc localhost 8080
Hello from TCP server

通過 net 包,我們可以很簡單的去寫一個 TCP 伺服器,程式碼可讀性強。

TCP 客戶端

那麼我們能不能用 Go 語言來模擬客戶端,從而連線前面的伺服器呢?答案是肯定的。

package main

import (
    "fmt"
    "io/ioutil"
    "log"
    "net"
)

func main() {
    // net 包的 Dial 函式能建立一個 TCP 連線
    conn, err := net.Dial("tcp", ":8080")
    if err != nil {
        log.Fatal(err)
    }
    // 別忘了關閉連線
    defer conn.Close()
    // 通過 ioutil 來讀取連線中的內容,返回一個 []byte 型別的物件
    byte, err := ioutil.ReadAll(conn)
    if err != nil {
        log.Println(err)
    }
    // []byte 型別的資料轉成字串型,再將其列印輸出
    fmt.Println(string(byte))
}

執行伺服器後,再在所在的資料夾下啟動客戶端,會看到來自伺服器的問候。

$ go run main.go
Hello from TCP server

TCP 協議模擬 HTTP 請求

我們知道 TCP/IP 協議是傳輸層協議,主要解決的是資料如何在網路中傳輸。而 HTTP 是應用層協議,主要解決的是如何包裝這些資料。

下面的七層網路協議圖也能看到 HTTP 協議是處於 TCP 的上層,也就是說,HTTP 使用 TCP 來傳輸其報文資料。

七層網路協議圖

現在我們寫一個基於 TCP 協議的伺服器,並能模擬。在這其中,我們需要模擬傳送 HTTP 響應頭資訊,我們可以用 curl -i 命令先來檢視一下其他網站的響應頭資訊。

$ curl -i "www.baidu.com"
HTTP/1.1 200 OK  # HTTP 協議及請求碼
Server: bfe/1.0.8.18    # 伺服器使用的WEB軟體名及版本
Date: Sat, 29 Apr 2017 07:30:33 GMT  # 傳送時間
Content-Type: text/html   # 內容型別
Content-Length: 277            # 內容長度
Last-Modified: Mon, 13 Jun 2016 02:50:23 GMT
...  # balabala
Accept-Ranges: bytes

<!DOCTYPE html>  # 訊息體
<!--STATUS OK--><html>
...
</body> </html>

接下來,我們嘗試寫出能輸出對應格式響應內容的伺服器。

package main

import (
    "fmt"
    "log"
    "net"
)

func main() {
    li, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatalln(err.Error())
    }
    defer li.Close()

    for {
        conn, err := li.Accept()
        if err != nil {
            log.Fatalln(err.Error())
            continue
        }
        // 函式前新增 go 關鍵字,就能使其擁有 Go 語言的併發功能
        // 這樣我們可以同時處理來自不同客戶端的請求
        go handle(conn)
    }
}

func handle(conn net.Conn) {
    defer conn.Close()
    // 回應客戶端的請求
    respond(conn)
}

func respond(conn net.Conn) {
    // 訊息體
    body := `<!DOCTYPE html><html lang="en"><head><meta charet="UTF-8"><title>Go example</title></head><body><strong>Hello World</strong></body></html>`
    // HTTP 協議及請求碼
    fmt.Fprint(conn, "HTTP/1.1 200 OK\r\n")
    // 內容長度
    fmt.Fprintf(conn, "Content-Length: %d\r\n", len(body)) 
    // 內容型別
    fmt.Fprint(conn, "Content-Type: text/html\r\n")
    fmt.Fprint(conn, "\r\n")
    fmt.Fprint(conn, body)
}

go run main.go 啟動伺服器之後,跳轉到 localhost:8080,就能看到網頁內容,並且用開發者工具能看到其請求頭。

最簡單的 HTTP 伺服器

幾行程式碼就能實現一個最簡單的 HTTP 伺服器。

package main

import "net/http"

func main() {
    http.ListenAndServe(":8080", nil)
}

開啟後會發現顯示「404 page not found」,這說明 HTTP 已經開始服務了!

ListenAndServe

Go 是通過一個函式 ListenAndServe 來處理這些事情的,這個底層其實這樣處理的:初始化一個server 物件,然後呼叫了 net.Listen("tcp", addr),也就是底層用 TCP 協議搭建了一個服務,然後監控我們設定的埠。

《Build web application with golang》, astaxie

前面我們已經對 TCP 伺服器有點熟悉了,而 HTTP 使用 TCP 來傳輸其報文資料,接下來看看如何用 net/http 包來實現在其上的 HTTP 層。

查文件可以發現 http 包下的 ListenAndServe 函式第一個引數是地址,而第二個是 Handler 型別的引數,我們想要顯示內容就要在第二個引數下功夫。

func ListenAndServe(addr string, handler Handler) error

再次查文件,得知 Handler 是一個介面,也就是說只要我們給某一個型別建立 ServeHTTP(ResponseWriter, *Request) 方法,就能符合介面的要求,也就實現了介面。

type Handler interface {
        ServeHTTP(ResponseWriter, *Request)
}

下面是例子

package main

import (
    "fmt"
    "net/http"
)
// 建立一個 foo 型別
type foo struct {}
// 為 foo 型別建立 ServeHTTP 方法,以實現 Handle 介面
func (f foo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Implement the Handle interface.")
}

func main() {
    // 建立物件,型別名寫後面..
    var f foo
    http.ListenAndServe(":8080",f)
}

執行程式碼後開啟能看到輸出的字串。

*http.Request

上面我們實現的小伺服器裡,我們無論訪問 localhost:8080 還是 localhost:8080/foo 都是一樣的頁面,這說明我們之前設定的是預設的頁面,還沒有為特定的路由(route)設定內容。

路由這些資訊實際上就存在 ServeHTTP 函式的第二個引數 *http.Request 中, *http.Request 存放著客戶端傳送至伺服器的請求資訊,例如請求連結、請求方法、響應頭、訊息體等等。

現在我們可以把上面的程式碼改造一下。

package main

import (
    "fmt"
    "net/http"
)
// 建立一個 foo 型別
type foo struct {}
// 為 foo 型別建立 ServeHTTP 方法,以實現 Handle 介面
func (f foo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // 根據 URL 的相對路徑來設定網頁內容(不優雅)
    switch r.URL.Path {
    case "/boy":
        fmt.Fprintln(w, "I love you!!!")
    case "/girl":
        fmt.Fprintln(w, "hehe.")
    default:
        fmt.Fprintln(w, "Men would stop talking and women would shed tears when they see this.")
}

func main() {
    // 建立物件,型別名寫後面..
    var f foo
    http.ListenAndServe(":8080",f)
}

再優雅一點

我們可以用 HTTP 請求多路複用器(HTTP request multiplexer) 來實現分發路由,而http.NewServeMux() 返回的 *ServeMux 物件就能實現這樣的功能。下面是 *ServeMux 的部分原始碼,能看到通過 *ServeMux 就能為每一個路由設定單獨的一個 handler 了,簡單地說就是不同的內容。

type ServeMux struct {
    mu    sync.RWMutex         // 讀寫鎖
    m     map[string]muxEntry  // 路由資訊(鍵值對)
    hosts bool                 // 是否包含 hostnames
}

type muxEntry struct {
    explicit bool     // 是否精確匹配
    h        Handler  // muxEntry.Handler 是介面
    pattern  string   // 路由
}

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

*ServeMux 來寫一個例子。

package main

import (
    "fmt"
    "net/http"
)

type boy struct{}

func (b boy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "I love you!!!")
}

type girl struct{}

func (g girl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hehe.")
}

type foo struct{}

func (f foo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Men would stop talking and women would shed tears when they see this.")
}

func main() {
    var b boy
    var g girl
    var f foo

    // 返回一個 *ServeMux 物件
    mux := http.NewServeMux()  
    mux.Handle("/boy/", b)
    mux.Handle("/girl/", g)
    mux.Handle("/", f)
    http.ListenAndServe(":8080", mux)
}

這樣就能為每一個路由設定單獨的頁面了。

再再優雅一點

http.Handle(pattern string, handler Handler) 還能幫我們簡化程式碼,它預設建立一個 DefaultServeMux,也就是預設的 ServeMux 來存 handler 資訊,這樣就不需要 http.NewServeMux() 函式了。這看起來雖然沒有什麼少寫多少程式碼,但是這是下一個更加優雅方法的轉折點。

package main

import (
    "fmt"
    "net/http"
)

type boy struct{}

func (b boy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "I love you!!!")
}

type girl struct{}

func (g girl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hehe.")
}

type foo struct{}

func (f foo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Men would stop talking and women would shed tears when they see this.")
}

func main() {
    var b boy
    var g girl
    var f foo

    http.Handle("/boy/", b)
    http.Handle("/girl/", g)
    http.Handle("/", f)
    http.ListenAndServe(":8080", nil)
}

再再再優雅一點

http.HandleFunc(pattern string, handler func(ResponseWriter, *Request)) 可以看做 http.Handle(pattern string, handler Handler) 的一種包裝。前者的第二個引數變成了一個函式,這樣我們就不用多次新建物件,再為物件實現 ServeHTTP() 方法來實現不同的 handler 了。下面是 http.HandleFun() 的部分原始碼。

func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    // 同樣利用 DefaultServeMux 來存路由資訊
    DefaultServeMux.HandleFunc(pattern, handler)
}

func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    // 是不是似曾相識?
    mux.Handle(pattern, HandlerFunc(handler))
}

http.HandleFun() 來重寫之前的例子。

package main

import (
    "fmt"
    "net/http"
)

func boy(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "I love you!!!")
}

func girl(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hehe.")
}

func foo(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Men would stop talking and women would shed tears when they see this.")
}

func main() {
    http.HandleFunc("/boy/", boy)
    http.HandleFunc("/girl/", girl)
    http.HandleFunc("/", foo)
    http.ListenAndServe(":8080", nil)
}

HandlerFunc

另外,http 包裡面還定義了一個型別 http.HandlerFunc,該型別預設實現 Handler 介面,我們可以通過 HandlerFunc(foo) 的方式來實現型別強轉,使 foo 也實現了 Handler 介面。

type HandlerFunc func(ResponseWriter, *Request)

// 實現 Handler 介面
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

下面是例子

package main

import (
    "fmt"
    "net/http"
)

func boy(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "I love you!!!")
}

func girl(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hehe.")
}

func foo(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Men would stop talking and women would shed tears when they see this.")
}

func main() {
    // http.Handler() 的第二個引數是要實現了 Handler 介面的型別
    // 可以通過型別強轉來重新使用該函式來實現
    http.Handle("/boy/", http.HandlerFunc(boy))
    http.Handle("/girl/", http.HandlerFunc(girl))
    http.Handle("/", http.HandlerFunc(foo))
    http.ListenAndServe(":8080", nil)
}

結尾

本文從搭建 TCP 伺服器一步步到搭建 HTTP 伺服器,展示了 Go 語言網路庫的強大,我認為 Go 語言是熟悉網路協議的一個很好的工具。自己從熟悉了擁有各種 feature 的 Swift 語言之後再入門到看似平凡無奇的 Go 語言,經歷了從為語言的平庸感到驚訝不解到為其遵循規範和良好的工業語言設計而感到驚歎和興奮的轉變。

最後希望本文能為有基礎的同學理清思路,也能吸引更多同學來學習這門優秀的語言。

相關文章