Go語言TCP網路程式設計

2020-07-16 10:05:03
TCP 工作在網路的傳輸層,它屬於一種面向連線的可靠的通訊協定。TCP 網路程式設計屬於 C-S 模式,一般要設計一個伺服器程式,一個或多個客戶機程式。另外,TCP 是面向連線的通訊協定,所以客戶機要和伺服器進行通訊,首先要在通訊雙方之間建立通訊連線。本節將詳細講解 TCP 網路程式設計伺服器、客戶機的設計原理和設計過程。

TCPAddr 地址結構體

在進行 TCP 網路程式設計時,伺服器或客戶機的地址使用 TCPAddr 地址結構體表示,TCPAddr 包含兩個欄位:IP 和 Port,形式如下:

type TCPAddr struct {
    IP IP
    Port int
}

函數 ResolveTCPAddr() 可以把網路地址轉換為 TCPAddr 地址結構,該函數原型定義如下:

func ResolveTCPAddr(net, addr string) (*TCPAddr, error)

在呼叫函數 ResolveTCPAddr() 時,引數 net 是網路協定名,可以是“tcp”、“tcp4”或“tcp6”。引數 addr 是 IP 地址或域名,如果是 IPv6 地址則必須使用“[]”括起來。另外,埠號以“:”的形式跟隨在 IP 地址或域名的後而,埠是可選的。例如:“www.google.com:80”或“127.0.0.1:21”。

還有一種特例,就是對於 HTTP 伺服器,當主機地址為本地測試地址時 (127.0.0.1),可以直接使用埠號作為 TCP 連線地址,形如“:80”。

函數 ResolveTCPAddr() 呼叫成功後返回一個指向 TCPAddr 結構體的指標,否則返回一個錯誤型別。

另外,TCPAddr 地址物件還有兩個方法:Network() 和 String(),Network() 方法用於返回 TCPAddr 地址物件的網路協定名,比如“tcp”;String() 方法可以將 TCPAddr 地址轉換成字串形式。這兩個方法原型定義如下:

func (a *TCPAddr) Network() string
func (a *TCPAddr) String() string

【範例 1】TCP 連線地址。
import(
    "fmt"
    "net"
    "os"
)
func main() {
    if len(os.Args) != 3 {
        fmt.Fprintf(os.Stderr, "Usage: %s networkType addrn", os.Args[0])
        os.Exit(1)
    }
    networkType := os.Args[1]
    addr := os.Args[2]
    tcpAddr, err := net.ResolveTCPAddr(networkType, addr)
    if err != nil {
        fmt.Println("ResolveTCPAddr error: ", err.Error())
        os.Exit(1)
    }
    fmt.Println("The network type is: ", tcpAddr.Network())
    fmt.Println("The IP address is: ", tcpAddr.String())
    os.Exit(0)
}
編譯並執行該程式,測試過程如下:

PS D:code> go run .main.go tcp c.biancheng.net:80
                     The network type is:  tcp
                     The IP address is:  61.240.154.115:80

TCPConn 物件

在進行 TCP 網路程式設計時,客戶機和伺服器之間是通過 TCPConn 物件實現連線的,TCPConn 是 Conn 介面的實現。TCPConn 物件系結了伺服器的網路協定和地址資訊,TCPConn 物件定義如下:

type TCPConn struct {
    //空結構
)

通過 TCPConn 連線物件,可以實現客戶機和伺服器間的全雙工通訊。可以通過 TCPConn 物件的 Read() 方法和 Write() 方法,在伺服器和客戶機之間傳送和接收資料。Read() 方法和 Write() 方法的原型定義如下:

func (c *TCPConn) Read(b []byte) (n int, err error)
func (c *TCPConn) Write(b []byte) (n int, err error)

Read() 方法呼叫成功後會返回接收到的位元組數,呼叫失敗返回一個錯誤型別;Write() 方法呼叫成功後會返回正確傳送的位元組數,呼叫失敗返回一個錯誤型別。另外,這兩個方法的執行都會引起阻塞。

TCP 伺服器設計

前面講了 Go語言網路程式設計和傳統 Socket 網路程式設計有所不同,TCP 伺服器的工作過程如下:

1) TCP 伺服器首先註冊一個公知埠,然後呼叫 ListenTCP() 函數在這個埠上建立一個 TCPListener 監聽物件,並在該物件上監聽客戶機的連線請求。

2) 啟用 TCPListener 物件的 Accept() 方法接收客戶機的連線請求,並返回一個協定相關的 Conn 物件,這裡就是 TCPConn 物件。

3) 如果返回了一個新的 TCPConn 物件,伺服器就可以呼叫該物件的 Read() 方法接收客戶機發來的資料,或者呼叫 Write() 方法向客戶機傳送資料了。

TCPListener 物件、ListenTCP() 函數的原型定義如下:

type TCPListener struct {
    //contains filtered or unexported fields
}
func ListenTCP(net string, laddr *TCPAddr) (*TCPListener, error)

在呼叫函數 ListenTCP() 時,引數 net 是網路協定名,可以是“tcp”、“tcp4”或“tcp6”。引數 laddr 是伺服器本地地址,可以是任意活動的主機地址,或者是內部測試地址“127.0.0.1”。該函數呼叫成功,返回一個 TCPListener 物件;呼叫失敗,返回一個錯誤型別。

TCPListener 物件的 Accept() 方法原型定義如下:       

func (l *TCPListener) Accept() (c Conn, err error)

Accept() 方法呼叫成功後,返回 TCPConn 物件;否則,返回一個錯誤型別。

伺服器和客戶機的通訊連線建立成功後,就可以使用 Read() 和 Write() 方法收發資料。在通訊過程中,如果還想獲取通訊雙方的地址資訊,可以使用 LocalAddr() 方法和 RemoteAddr() 方法來完成,這兩個方法原型定義如下:

func (c *TCPConn) LocalAddr() Addr
func (c *TCPConn) RemoteAddr() Addr

LocalAddr() 方法會返回本地主機地址,RemoteAddr() 方法返回遠端主機地址。

【範例 2】TCP Server 端設計,伺服器使用本地地址,伺服器端口號為 5000。伺服器設計工作模式採用迴圈伺服器,對每一個連線請求呼叫執行緒 handleClient 來處理。
// TCP Server 端設計
package main

import(
    "fmt"
    "net"
    "os"
)
func main() {
    service := ":5000"
    tcpAddr, err := net.ResolveTCPAddr("tcp", service)
    checkError(err)
    listener, err := net.ListenTCP("tcp", tcpAddr)
    checkError(err)
    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        handleClient(conn)
        conn.Close()
    }
}
func handleClient(conn net.Conn) {
    var buf [512]byte
    for {
        n, err := conn.Read(buf[0:])
        if err != nil {
            return
        }
        rAddr := conn.RemoteAddr()
    fmt.Println("Receive from client", rAddr.String(), string(buf[0:n]))
        _, err2 := conn.Write([]byte("Welcome client"))
        if err2 != nil {
            return
        }
    }
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error %s", err.Error())
        os.Exit(1)
    }
}

TCP 客戶機設計

在 TCP 網路程式設計中,客戶機的工作過程如下:

1) TCP 客戶機在獲取了伺服器的伺服器端口號和服務地址之後,可以呼叫 DialTCP() 函數向伺服器發出連線請求,如果請求成功會返回 TCPConn 物件。

2) 客戶機呼叫 TCPConn 物件的 Read() 或 Write() 方法,與伺服器進行通訊活動。

3) 通訊完成後,客戶機呼叫 Close() 方法關閉連線,斷開通訊鏈路。

DialTCP() 函數原型定義如下:

Func DialTCP(net string, laddr, raddr *TCPAddr) (*TCPConn, error)

在呼叫函數 DialTCP() 時,引數 net 是網路協定名,可以是“tcp”、“tcp4”或“tcp6”。引數 laddr 是本地主機地址,可以設為 nil。引數 raddr 是對方主機地址,必須指定不能省略。函數呼叫成功後,返回 TCPConn 物件;呼叫失敗,返回一個錯誤型別。

方法 Close() 的原型定義如下:

func (c *TCPConn) Close() error

該方法呼叫成功後,關閉 TCPConn 連線;呼叫失敗,返回一個錯誤型別。

【範例 3】TCP Client 端設計,客戶機通過內部測試地址“127.0.0.1”和埠 5000 和伺服器建立通訊連線。
// TCP Client端設計
package main

import(
    "fmt"
    "net"
    "os"
)
func main() {
    var buf [512]byte
    if len(os.Args) != 2 {
        fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
    }
    service := os.Args[1]
    tcpAddr, err := net.ResolveTCPAddr("tcp", service)
    checkError(err)
    conn, err := net.DialTCP("tcp", nil, tcpAddr)
    checkError(err)
    rAddr := conn.RemoteAddr()
    n, err := conn.Write([]byte("Hello server"))
    checkError(err)
    n, err = conn.Read(buf[0:])
    checkError(err)
    fmt.Println("Reply form server", rAddr.String(), string(buf[0:n]))
    conn.Close()
    os.Exit(0)
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}
編譯並執行伺服器端和用戶端,測試過程如下:

啟動伺服器:go run .main.go
客戶機連線:go run .client.go 127.0.0.1:5000
伺服器響應:Receive from client 127.0.0.1:50813 Hello server
客戶機接收:Reply form server 127.0.0.1:5000 Welcome client

從上述測試結果可以看出,伺服器註冊了一個公知埠 5000,而當客戶機與伺服器建立連線後,客戶機會生成一個臨時埠“50813”與伺服器進行通訊。伺服器不管啟動多少次埠號都是 5000,而客戶機每一次重新啟動埠號都不一樣。

使用 Goroutine 實現並行伺服器

前面的講解中伺服器設計採用迴圈伺服器設計模式,這種伺服器設計簡單但缺陷明顯。因為這種伺服器一旦啟動,就一直阻塞監聽客戶機的連線請求,直至伺服器關閉。所以,迴圈伺服器很耗費系統資源。

解決問題的方法是採用並行伺服器模式,在這種模式中,對每一個客戶機的連線請求,伺服器都會建立一個新的進程、執行緒或者協程進行響應,而伺服器還可以去處理其他任務。Goroutine 即協程是一種比執行緒更輕量級的任務單位,所以這裡就使用 Goroutine 來實現並行伺服器的設計。

【範例 4】並行伺服器設計,伺服器使用本地地址,伺服器端口號為 5000。伺服器設計工作模式採用並行伺服器模式,對每一個連線請求建立一個能呼叫 handleClient() 函數的 Goroutine 來處理。
// TCP Server 端設計
package main

import(
    "fmt"
    "net"
    "os"
)
func main() {
    service := ":5000"
    tcpAddr, err := net.ResolveTCPAddr("tcp", service)
    checkError(err)
    listener, err := net.ListenTCP("tcp", tcpAddr)
    checkError(err)
    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        go handleClient(conn)   //建立 Goroutine
    }
}
func handleClient(conn net.Conn) {
    defer conn.Close()          //逆序呼叫 Close() 保證連線能正常關閉
    var buf [512]byte
    for {
        n, err := conn.Read(buf[0:])
        if err != nil {
            return
        }
        rAddr := conn.RemoteAddr()
    fmt.Println("Receive from client", rAddr.String(), string(buf[0:n]))
        _, err2 := conn.Write([]byte("Welcome client"))
        if err2 != nil {
            return
        }
    }
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error %s", err.Error())
        os.Exit(1)
    }
}
編譯並執行伺服器端和用戶端,測試過程如下、:

啟動伺服器:go run .main.go
客戶機連線:go run .client.go 127.0.0.1:5000
伺服器響應:Receive from client 127.0.0.1:51369 Hello server
客戶機接收:Reply form server 127.0.0.1:5000 Welcome client

通過測試可以發現,並行伺服器可以同時響應多個客戶機的連線請求,並能和多個客戶機並行通訊,尤其在多核心系統平台上,這種通訊模式效率更高。而迴圈伺服器只能按客戶機的請求佇列次序,一個一個地為客戶機提供通訊服務,通訊效率低下。