Socket 程式設計

在很多底層網路應用開發者的眼裡一切程式設計都是 Socket,話雖然有點誇張,但卻也幾乎如此了,現在的網路程式設計幾乎都是用 Socket 來程式設計。你想過這些情景麼?我們每天開啟瀏覽器瀏覽網頁時,瀏覽器程序怎麼和 Web 伺服器進行通訊的呢?當你用 QQ 聊天時,QQ 程序怎麼和伺服器或者是你的好友所在的 QQ 程序進行通訊的呢?當你開啟 PPstream 觀看視訊時,PPstream 程序如何與視訊伺服器進行通訊的呢? 如此種種,都是靠 Socket 來進行通訊的,以一斑窺全豹,可見 Socket 程式設計在現代程式設計中佔據了多麼重要的地位,這一節我們將介紹 Go 語言中如何進行 Socket 程式設計。

什麼是 Socket?

Socket 起源於 Unix,而 Unix 基本哲學之一就是“一切皆檔案”,都可以用“開啟 open –> 讀寫 write/read –> 關閉 close”模式來操作。Socket 就是該模式的一個實現,網路的 Socket 資料傳輸是一種特殊的 I/O,Socket 也是一種檔案描述符。Socket 也具有一個類似於開啟檔案的函式呼叫:Socket(),該函式回傳一個整型的 Socket 描述符,隨後的連線建立、資料傳輸等操作都是透過該 Socket 實現的。

常用的 Socket 型別有兩種:串流式的 Socket(SOCK_STREAM)和資料報式的 Socket(SOCK_DGRAM)。串流式是一種連線導向的 Socket,針對於連線導向的 TCP 服務應用;資料報式 Socket 是一種無連線的 Socket,對應於無連線的 UDP 服務應用。

Socket 如何通訊

網路中的程序之間如何透過 Socket 通訊呢?首要解決的問題是如何唯一標識一個程序,否則通訊無從談起!在本地可以透過程序 PID 來唯一標識一個程序,但是在網路中這是行不通的。其實 TCP/IP 協議族已經幫我們解決了這個問題,網路層的“ip 地址”可以唯一標識網路中的主機,而傳輸層的“協議+埠”可以唯一標識主機中的應用程式(程序)。這樣利用三元組(ip 地址,協議,埠)就可以標識網路的程序了,網路中需要互相通訊的程序,就可以利用這個標誌在他們之間進行互動。請看下面這個 TCP/IP 協議結構圖

圖 8.1 七層網路協議圖

使用 TCP/IP 協議的應用程式通常採用應用程式設計介面:UNIX BSD 的套接字(socket)和 UNIX System V 的 TLI(已經被淘汰),來實現網路程序之間的通訊。就目前而言,幾乎所有的應用程式都是採用 socket,而現在又是網路時代,網路中程序通訊是無處不在,這就是為什麼說“一切皆 Socket”。

Socket 基礎知識

透過上面的介紹我們知道 Socket 有兩種:TCP Socket 和 UDP Socket,TCP 和 UDP 是協議,而要確定一個程序的需要三元組,需要 IP 地址和埠。

IPv4 地址

目前的全球因特網所採用的協議族是 TCP/IP 協議。IP 是 TCP/IP 協議中網路層的協議,是 TCP/IP 協議族的核心協議。目前主要採用的 IP 協議的版本號是 4(簡稱為 IPv4),發展至今已經使用了 30 多年。

IPv4 的地址位數為 32 位,也就是最多有 2 的 32 次方的網路裝置可以聯到 Internet 上。近十年來由於網際網路的蓬勃發展,IP 位址的需求量愈來愈大,使得 IP 位址的發放愈趨緊張,前一段時間,據報道 IPV4 的地址已經發放完畢,我們公司目前很多伺服器的 IP 都是一個寶貴的資源。

地址格式類似這樣:127.0.0.1 172.122.121.111

IPv6 地址

IPv6 是下一版本的網際網路協議,也可以說是下一代網際網路的協議,它是為了解決 IPv4 在實施過程中遇到的各種問題而被提出的,IPv6 採用 128 位地址長度,幾乎可以不受限制地提供地址。按保守方法估算 IPv6 實際可分配的地址,整個地球的每平方米麵積上仍可分配 1000 多個地址。在 IPv6 的設計過程中除了一勞永逸地解決了地址短缺問題以外,還考慮了在 IPv4 中解決不好的其它問題,主要有端到端 IP 連線、服務品質(QoS)、安全性、多播、移動性、即插即用等。

地址格式類似這樣:2002:c0e8:82e7:0:0:0:c0e8:82e7

Go 支援的 IP 型別

在 Go 的net套件中定義了很多型別、函式和方法用來網路程式設計,其中 IP 的定義如下:

type IP []byte

net 套件中有很多函式來操作 IP,但是其中比較有用的也就幾個,其中ParseIP(s string) IP函式會把一個 IPv4 或者 IPv6 的地址轉化成 IP 型別,請看下面的例子:

package main
import (
    "net"
    "os"
    "fmt"
)
func main() {
    if len(os.Args) != 2 {
        fmt.Fprintf(os.Stderr, "Usage: %s ip-addr\n", os.Args[0])
        os.Exit(1)
    }
    name := os.Args[1]
    addr := net.ParseIP(name)
    if addr == nil {
        fmt.Println("Invalid address")
    } else {
        fmt.Println("The address is ", addr.String())
    }
    os.Exit(0)
}

執行之後你就會發現只要你輸入一個 IP 地址就會給出相應的 IP 格式

TCP Socket

當我們知道如何透過網路埠存取一個服務時,那麼我們能夠做什麼呢?作為客戶端來說,我們可以透過向遠端某臺機器的的某個網路埠傳送一個請求,然後得到在機器的此埠上監聽的服務反饋的資訊。作為伺服器端,我們需要把服務繫結到某個指定埠,並且在此埠上監聽,當有客戶端來存取時能夠讀取資訊並且寫入反饋資訊。

在 Go 語言的 net 套件中有一個型別TCPConn,這個型別可以用來作為客戶端和伺服器端互動的通道,他有兩個主要的函式:

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

TCPConn可以用在客戶端和伺服器端來讀寫資料。

還有我們需要知道一個 TCPAddr 型別,他表示一個 TCP 的地址資訊,他的定義如下:

type TCPAddr struct {
    IP IP
    Port int
    Zone string // IPv6 scoped addressing zone
}

在 Go 語言中透過 ResolveTCPAddr 取得一個TCPAddr

func ResolveTCPAddr(net, addr string) (*TCPAddr, os.Error)
  • net 參數是"tcp4"、"tcp6"、"tcp"中的任意一個,分別表示 TCP(IPv4-only), TCP(IPv6-only)或者 TCP(IPv4, IPv6 的任意一個)。

  • addr 表示域名或者 IP 地址,例如"www.google.com:80" 或者"127.0.0.1:22"。

TCP client

Go 語言中透過 net 套件中的 DialTCP 函式來建立一個 TCP 連線,並回傳一個 TCPConn 型別的物件,當連線建立時伺服器端也建立一個同類型的物件,此時客戶端和伺服器端透過各自擁有的 TCPConn 物件來進行資料交換。一般而言,客戶端透過 TCPConn 物件將請求資訊傳送到伺服器端,讀取伺服器端回應的資訊。伺服器端讀取並解析來自客戶端的請求,並回傳回應資訊,這個連線只有當任一端關閉了連線之後才失效,不然這連線可以一直在使用。建立連線的函式定義如下:

func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error)
  • network 參數是"tcp4"、"tcp6"、"tcp"中的任意一個,分別表示 TCP(IPv4-only)、TCP(IPv6-only)或者 TCP(IPv4,IPv6 的任意一個)

  • laddr 表示本機地址,一般設定為 nil

  • raddr 表示遠端的服務地址

接下來我們寫一個簡單的例子,模擬一個基於 HTTP 協議的客戶端請求去連線一個 Web 伺服器端。我們要寫一個簡單的 http 請求頭,格式類似如下:

"HEAD / HTTP/1.0\r\n\r\n"

從伺服器端接收到的回應資訊格式可能如下:

HTTP/1.0 200 OK
ETag: "-9985996"
Last-Modified: Thu, 25 Mar 2010 17:51:10 GMT
Content-Length: 18074
Connection: close
Date: Sat, 28 Aug 2010 00:43:48 GMT
Server: lighttpd/1.4.23

我們的客戶端程式碼如下所示:

package main

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

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintf(os.Stderr, "Usage: %s host:port ", os.Args[0])
        os.Exit(1)
    }
    service := os.Args[1]
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    conn, err := net.DialTCP("tcp", nil, tcpAddr)
    checkError(err)
    _, err = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))
    checkError(err)
    result, err := ioutil.ReadAll(conn)
    checkError(err)
    fmt.Println(string(result))
    os.Exit(0)
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

透過上面的程式碼我們可以看出:首先程式將使用者的輸入作為參數 service 傳入net.ResolveTCPAddr取得一個 tcpAddr,然後把 tcpAddr 傳入 DialTCP 後建立了一個 TCP 連線conn,透過 conn 來發送請求資訊,最後透過ioutil.ReadAllconn 中讀取全部的文字,也就是伺服器端回應反饋的資訊。

TCP server

上面我們編寫了一個 TCP 的客戶端程式,也可以透過 net 套件來建立一個伺服器端程式,在伺服器端我們需要繫結服務到指定的非啟用埠,並監聽此埠,當有客戶端請求到達的時候可以接收到來自客戶端連線的請求。net 套件中有相應功能的函式,函式定義如下:

func ListenTCP(network string, laddr *TCPAddr) (*TCPListener, error)
func (l *TCPListener) Accept() (Conn, error)

參數說明同 DialTCP 的參數一樣。下面我們實現一個簡單的時間同步服務,監聽 7777 埠

package main

import (
    "fmt"
    "net"
    "os"
    "time"
)

func main() {
    service := ":7777"
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    listener, err := net.ListenTCP("tcp", tcpAddr)
    checkError(err)
    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        daytime := time.Now().String()
        conn.Write([]byte(daytime)) // don't care about return value
        conn.Close()                // we're finished with this client
    }
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

上面的服務跑起來之後,它將會一直在那裡等待,直到有新的客戶端請求到達。當有新的客戶端請求到達並同意接受 Accept 該請求的時候他會反饋當前的時間資訊。值得注意的是,在程式碼中 for 迴圈裡,當有錯誤發生時,直接 continue 而不是退出,是因為在伺服器端跑程式碼的時候,當有錯誤發生的情況下最好是由伺服器端記錄錯誤,然後當前連線的客戶端直接報錯而退出,從而不會影響到當前伺服器端執行的整個服務。

上面的程式碼有個缺點,執行的時候是單任務的,不能同時接收多個請求,那麼該如何改造以使它支援多併發呢?Go 裡面有一個 goroutine 機制,請看下面改造後的程式碼

package main

import (
    "fmt"
    "net"
    "os"
    "time"
)

func main() {
    service := ":1200"
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    listener, err := net.ListenTCP("tcp", tcpAddr)
    checkError(err)
    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        go handleClient(conn)
    }
}

func handleClient(conn net.Conn) {
    defer conn.Close()
    daytime := time.Now().String()
    conn.Write([]byte(daytime)) // don't care about return value
    // we're finished with this client
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

透過把業務處理分離到函式handleClient,我們就可以進一步地實現多併發執行了。看上去是不是很帥,增加 go 關鍵詞就實現了伺服器端的多併發,從這個小例子也可以看出 goroutine 的強大之處。

有的朋友可能要問:這個伺服器端沒有處理客戶端實際請求的內容。如果我們需要透過從客戶端傳送不同的請求來取得不同的時間格式,而且需要一個長連線,該怎麼做呢?請看:

package main

import (
    "fmt"
    "net"
    "os"
    "time"
    "strconv"
    "strings"
)

func main() {
    service := ":1200"
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    listener, err := net.ListenTCP("tcp", tcpAddr)
    checkError(err)
    for {
        conn, err := listener.Accept()
        if err != nil {
            continue
        }
        go handleClient(conn)
    }
}

func handleClient(conn net.Conn) {
    conn.SetReadDeadline(time.Now().Add(2 * time.Minute)) // set 2 minutes timeout
    request := make([]byte, 128) // set maxium request length to 128B to prevent flood attack
    defer conn.Close()  // close connection before exit
    for {
        read_len, err := conn.Read(request)

        if err != nil {
            fmt.Println(err)
            break
        }

            if read_len == 0 {
                break // connection already closed by client
            } else if strings.TrimSpace(string(request[:read_len])) == "timestamp" {
                daytime := strconv.FormatInt(time.Now().Unix(), 10)
                conn.Write([]byte(daytime))
            } else {
                daytime := time.Now().String()
                conn.Write([]byte(daytime))
            }

            request = make([]byte, 128) // clear last read content
    }
}

func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

在上面這個例子中,我們使用conn.Read()不斷讀取客戶端發來的請求。由於我們需要保持與客戶端的長連線,所以不能在讀取完一次請求後就關閉連線。由於conn.SetReadDeadline()設定了超時,當一定時間內客戶端無請求傳送,conn便會自動關閉,下面的 for 迴圈即會因為連線已關閉而跳出。需要注意的是,request在建立時需要指定一個最大長度以防止 flood attack;每次讀取到請求處理完畢後,需要清理 request,因為conn.Read()會將新讀取到的內容 append 到原內容之後。

控制 TCP 連線

TCP 有很多連線控制函式,我們平常用到比較多的有如下幾個函式:

func DialTimeout(net, addr string, timeout time.Duration) (Conn, error)

設定建立連線的超時時間,客戶端和伺服器端都適用,當超過設定時間時,連線自動關閉。

func (c *TCPConn) SetReadDeadline(t time.Time) error
func (c *TCPConn) SetWriteDeadline(t time.Time) error

用來設定寫入/讀取一個連線的超時時間。當超過設定時間時,連線自動關閉。

func (c *TCPConn) SetKeepAlive(keepalive bool) os.Error

設定 keepAlive 屬性,是作業系統層在 tcp 上沒有資料和 ACK 的時候,會間隔性的傳送 keepalive 套件,作業系統可以透過該套件來判斷一個 tcp 連線是否已經斷開,在 windows 上預設 2 個小時沒有收到資料和 keepalive 套件的時候人為 tcp 連線已經斷開,這個功能和我們通常在應用層加的心跳套件的功能類似。

更多的內容請檢視 net 套件的文件。

UDP Socket

Go 語言套件中處理 UDP Socket 和 TCP Socket 不同的地方就是在伺服器端處理多個客戶端請求資料套件的方式不同,UDP 缺少了對客戶端連線請求的 Accept 函式。其他基本幾乎一模一樣,只有 TCP 換成了 UDP 而已。UDP 的幾個主要函式如下所示:

func ResolveUDPAddr(net, addr string) (*UDPAddr, os.Error)
func DialUDP(net string, laddr, raddr *UDPAddr) (c *UDPConn, err os.Error)
func ListenUDP(net string, laddr *UDPAddr) (c *UDPConn, err os.Error)
func (c *UDPConn) ReadFromUDP(b []byte) (n int, addr *UDPAddr, err os.Error)
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (n int, err os.Error)

一個 UDP 的客戶端程式碼如下所示,我們可以看到不同的就是 TCP 換成了 UDP 而已:

package main

import (
    "fmt"
    "net"
    "os"
)

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintf(os.Stderr, "Usage: %s host:port", os.Args[0])
        os.Exit(1)
    }
    service := os.Args[1]
    udpAddr, err := net.ResolveUDPAddr("udp4", service)
    checkError(err)
    conn, err := net.DialUDP("udp", nil, udpAddr)
    checkError(err)
    _, err = conn.Write([]byte("anything"))
    checkError(err)
    var buf [512]byte
    n, err := conn.Read(buf[0:])
    checkError(err)
    fmt.Println(string(buf[0:n]))
    os.Exit(0)
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error %s", err.Error())
        os.Exit(1)
    }
}

我們來看一下 UDP 伺服器端如何來處理:

package main

import (
    "fmt"
    "net"
    "os"
    "time"
)

func main() {
    service := ":1200"
    udpAddr, err := net.ResolveUDPAddr("udp4", service)
    checkError(err)
    conn, err := net.ListenUDP("udp", udpAddr)
    checkError(err)
    for {
        handleClient(conn)
    }
}
func handleClient(conn *net.UDPConn) {
    var buf [512]byte
    _, addr, err := conn.ReadFromUDP(buf[0:])
    if err != nil {
        return
    }
    daytime := time.Now().String()
    conn.WriteToUDP([]byte(daytime), addr)
}
func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error %s", err.Error())
        os.Exit(1)
    }
}

總結

透過對 TCP 和 UDP Socket 程式設計的描述和實現,可見 Go 已經完備地支援了 Socket 程式設計,而且使用起來相當的方便,Go 提供了很多函式,透過這些函式可以很容易就編寫出高效能的 Socket 應用。

Last updated