Files
build-web-application-with-…/zh-tw/08.1.md
2019-02-26 01:40:54 +08:00

17 KiB
Raw Blame History

8.1 Socket程式設計

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

什麼是Socket

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

常用的Socket型別有兩種串流式的SocketSOCK_STREAM和資料報式的SocketSOCK_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 SocketTCP和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應用。