Go36-46-訪問網路服務(socket)
訪問網路服務
這篇開始講網路程式設計。不過網路程式設計的內容過於龐大,這裡主要講socket。而socket可以講的東西也太多了,因此,這裡只圍繞Go語言介紹一些它的基礎知識。
IPC方法
所謂socket,是一種IPC(Inter-Process Communication)方法,可以被翻譯為程序間通訊。顧名思義,IPC這個概念(或者說規範)主要定義的是多個程序之間,相互通訊的方法。這些方法主要包括:
- 系統訊號(signal),os包和os/signal包有針對系統訊號的API
- 管道(pipe),os.Pipe函式可以建立命名管道,os/exec包支援另一類管道:匿名管道
- 套接字(socket),net包中提供支援
- 檔案鎖(file lock)
- 訊息佇列(message queue)
- 訊號燈(semaphore),也稱為訊號量
現存的主要作業系統大都對IPC提供了強有力的支援,尤其是socket。
socket
socket,常被稱作套接字,它是網路程式設計世界中最為核心的知識之一。
毫不誇張的說,在眾多IPC方法中,socket是最為通用和靈活的一種。與其他的IPC方法不同,利用socket進行通訊的程序,可以不侷限在同一臺計算機當中。通訊雙方只要能夠通過網路進行互聯,就可以使用socket。
支援socket的作業系統一般都會對外提供一套API。跑在它們之上的應用程式,利用這套API就可以與網際網路上的另一臺計算機中的程式、同一臺計算機中的其他程式,甚至同一個程式中的其他執行緒進行通訊。例如,在Linux作業系統中,用於建立socket例項的API,就是由一個名為socket的系統呼叫代表的。這個系統呼叫是Linux核心的一部分。所謂的系統呼叫,你可以理解為特殊的C語言函式。它們是連線應用程式和作業系統核心的橋樑,也是應用程式使用作業系統功能的唯一渠道。
syscall包
在Go語言標準庫的syscall包中,有一個與這個socket系統呼叫相對應的函式。這兩者的函式簽名是基本一致的,它們都會接受三個int型別的引數,並會返回一個可以代表檔案描述符的結果。但不同的是,syscall包中的Socket函式本身是平臺不相關的。在其底層,Go語言為它支援的每個作業系統都做了適配,這樣這個函式無論在哪個平臺上,總是有效的。
在syscall.Socket函式中的三個引數分別是:
- socket例項的通訊域
- socket例項的型別
- socket例項的使用協議
下面,通過這3個引數來了解一下socket的基礎知識。
通訊域
Socket的通訊域主要有3種,分別對應syscall包中的一個常量:
- AF_INET : IPv4域
- AF_INET6 : IPv6域
- AF_UNIX : Unix域
關於IPv4和IPv6就不講了,Unix域簡單提一下。
Unix域,指的是一種類Unix作業系統中特有的通訊域。在裝有此類作業系統的同一臺計算機中,應用程式可以基於此域建立socket連線。
型別
Socket的型別一個有4種,在syscall包中有同名的常量對應:
- SOCK_DGRAM
- SOCK_STREAM
- SOCK_SEQPACKET
- SOCK_RAW
上面的4種類型,前兩個更加常用。
SOCK_DGRA中的DGRAM就是datagram,即資料報文 。它是一種有訊息邊界但沒有邏輯連線的非可靠socket型別,UDP協議的網路通訊就是這類。
有訊息邊界的意思是,與socket相關的作業系統核心中的程式,即核心程式 ,在傳送或接收資料的時候是以訊息為單位的。這裡可以把訊息理解為帶有固定邊界的一段資料。核心程式可以自動的識別和維護這種邊界。在必要的時候,把資料切割成一個一個的訊息,或者把多個訊息串接成連續的資料。這樣,應用程式值需要面向訊息進行處理就可以了。
只要應用程式指定好對方的網路地址,核心程式就可以立即把資料報文傳送出去。這有優勢也有劣勢。優勢是,傳送速度快,不長期佔用網路資源,並且每次傳送都可以指定不同的網路地址。最後一條既是優勢也是劣勢,因為這會使資料報文更長。其他劣勢還有,無法保證傳輸的可靠性,不能實現資料的有序性,以及資料只能單向進行傳輸。
SOCK_STREAM型別,是沒有訊息邊界但有邏輯連線,能夠保證傳輸的可靠性和資料的有序性,同時還可以實現資料的雙向傳輸。TCP協議的網路通訊就是這類。
有邏輯連線是指,通訊雙方在收發資料之前必須先建立網路連線。等連線建立好之後,雙方就可以一對一的進行資料傳輸了。
這樣的網路通訊傳輸資料的形式是位元組流,而不是資料報文。位元組流是以位元組為單位的。核心程式無法感知一段位元組流中包含了多少個訊息,以及這些訊息是否完整,這完全需要應用程式自己來把控。不過,此類網路通訊中的一段,總會忠實的按照另一端傳送資料是的位元組排列順序,接收和快取它們。所以,應用程式需要根據雙方的約定去資料中查詢訊息邊界,並按照邊界切割資料。
使用協議
通常只要明確指定了前兩個引數值,就無需在去確定這裡的使用協議了,一般把它置為0就可以了。這時,核心程式會自行選擇最合適的協議。
不完整的示例
package main import ( "fmt" "os" "syscall" ) func main() { fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, syscall.IPPROTO_TCP) if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) return } defer syscall.Close(fd) fmt.Println("socket的檔案描述符:", fd) // 之後就省略了,要使用syscall包來建立網路連線,過程太繁瑣 }
這個程式碼包的使用太底層,通常也不需要我們直接使用。Go語言的net包中的很多程式實體,都會直接或間接的使用到syscall.Socket函式,並且無需給定細緻的引數。但是,在使用這些API的時候,現在我們就應該知道上面這些基礎知識了。
net.Dial函式
net.Dial函式會接受兩個引數,network和address,具體看下面:
func Dial(network, address string) (Conn, error) { var d Dialer return d.Dial(network, address) }
network引數
引數network常用的可選值一共有9個,這些值分別代表了程式底層建立的socket例項可使用的不同通訊協議:
- "tcp" : 代表TCP協議,其基於的IP協議的版本根據引數address的值自適應
- "tcp4" : 代表基於IPv4協議的TCP協議
- "tcp6" : 代表基於IPv6協議的TCP協議
- "udp" : 代表UDP協議,其基於的IP協議的版本根據address的值自適應
- "udp4" : 代表基於IPv4協議的UDP協議
- "udp6" : 代表基於IPv6協議的UDP協議
- "unix" : 代表Unix通訊域下的一種內部socket協議,以SOCK_STREAM為socket型別
- "unixgram" : 代表Unix通訊域下的一種內部socket協議,以SOCK_DGRAM為socket型別
- "unixpacket" : 代表Unix通訊域下的一種內部socket協議,以SOCK_SEQPACKET為socket型別
net包傳送http請求
對於http請求,在標準庫裡還有更高階的封裝,不過http本質上也是socket,這裡展示用net包傳送請求的示例:
package main import ( "fmt" "io" "net" "os" ) func main() { conn, err := net.Dial("tcp", "baidu.com:80") if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) return } defer conn.Close() reqStr := "HEAD / HTTP/1.1\r\n" + // HEAD請求,只返回請求頭 "Host: baidu.com\r\n" + "Connection: close\r\n" + // 返回後,伺服器會斷開連線,預設是keep-alive "\r\n"// 請求頭結束 _, err = io.WriteString(conn, reqStr) if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) return } buf := make([]byte, 1024) for { n, err := conn.Read(buf) fmt.Println(string(buf[:n])) if err != nil { if err == io.EOF { fmt.Println("END") break } else { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) } } } }
如果是https的請求,還需要藉助crypto/tls包,而呼叫起來基本是一樣的:
package main import ( "crypto/tls" "fmt" "io" "os" ) func main() { tlsConf := &tls.Config{ InsecureSkipVerify: true, MinVersion:tls.VersionTLS10, } conn, err := tls.Dial("tcp", "gitee.com:443", tlsConf) if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) return } defer conn.Close() reqStr := "HEAD / HTTP/1.1\r\n" + // HEAD請求,只返回請求頭 "Host: gitee.com\r\n" + "Connection: close\r\n" + // 返回後,伺服器會斷開連線,預設是keep-alive "\r\n" // 請求頭結束 _, err = io.WriteString(conn, reqStr) if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) return } buf := make([]byte, 1024) for { n, err := conn.Read(buf) fmt.Println(string(buf[:n])) if err != nil { if err == io.EOF { fmt.Println("END") break } else { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) } } } }
net.DialTimeout函式
net.DialTimeout函式和net.Dial函式相比,多接受了一個引數timeout。而底層實現可以看到是一樣的,只是對Dialer結構體的Timeout欄位進行了設定,而在net.Dial函式裡結構體都是預設值:
func DialTimeout(network, address string, timeout time.Duration) (Conn, error) { d := Dialer{Timeout: timeout} return d.Dial(network, address) }
超時時間
這裡的超時時間,出函式為網路連線建立完成而等待的最長時間。
開始的時間點幾乎是呼叫net.DialTimeout函式的那一刻。在這之後,時間會主要花費在解析引數的network值和address值,以及建立socket例項並建立網路連線這兩件事情上。如果超時了而網路連線還沒有建立完成,該函式就會返回一個I/O操作超時的錯誤值。
在解析address的值的時候,函式會確定網路服務的IP地址、埠號等必要資訊,並在需要的時候訪問DNS服務。另外,如果解析出的IP地址有多個,函式會序列或並行的嘗試建立連線。無論用什麼方式嘗試,函式總會以最先建立成功的那個連線為準。同時還會根據超時時間的剩餘時間去設定對每次連線嘗試的超時時間。
找一個國外的網站,或者乾脆找一個連不上的地址,看下超時時間的作用:
package main import ( "fmt" "net" "os" "time" ) func main() { tStart := time.Now() conn, err := net.DialTimeout("tcp", "godoc.org:80", time.Second * 10) tEnd := time.Now() fmt.Println("連線持續時間:", time.Duration(tEnd.Sub(tStart))) if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) return } defer conn.Close() fmt.Println("本地連線地址:", conn.LocalAddr()) fmt.Println("對端連線地址:", conn.RemoteAddr()) }