天天看點

golang socket程式設計(一)

作者:幹飯人小羽
golang socket程式設計(一)

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協定結構圖

golang socket程式設計(一)

使用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)           
  • net參數是"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.ReadAll從conn中讀取全部的文本,也就是服務端響應回報的資訊。

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包的文檔。