46 | 訪問網絡服務
前導內容:socket 與 IPC
人們常常會使用 Go 語言去編寫網絡程序(當然了,這方面也是 Go 語言最為擅長的事情)。說到網絡編程,我們就不得不提及 socket。
socket,常被翻譯為套接字,它應該算是網絡編程世界中最為核心的知識之一了。關於 socket,我們可以討論的東西太多了,因此,我在這里只圍繞着 Go 語言向你介紹一些關於它的基礎知識。
所謂 socket,是一種 IPC 方法。IPC 是 Inter-Process Communication 的縮寫,可以被翻譯為進程間通信。顧名思義,IPC 這個概念(或者說規范)主要定義的是多個進程之間,相互通信的方法。
這些方法主要包括:系統信號(signal)、管道(pipe)、套接字 (socket)、文件鎖(file lock)、消息隊列(message queue)、信號燈(semaphore,有的地方也稱之為信號量)等。現存的主流操作系統大都對 IPC 提供了強有力的支持,尤其是 socket。
你可能已經知道,Go 語言對 IPC 也提供了一定的支持。
比如,在os代碼包和os/signal代碼包中就有針對系統信號的 API。
又比如,os.Pipe函數可以創建命名管道,而os/exec代碼包則對另一類管道(匿名管道)提供了支持。對於 socket,Go 語言與之相應的程序實體都在其標准庫的net代碼包中。
毫不誇張地說,在眾多的 IPC 方法中,socket 是最為通用和靈活的一種。與其他的 IPC 方法不同,利用 socket 進行通信的進程,可以不局限在同一台計算機當中。
實際上,通信的雙方無論存在於世界上的哪個角落,只要能夠通過計算機的網卡端口以及網絡進行互聯,就可以使用 socket。
支持 socket 的操作系統一般都會對外提供一套 API。跑在它們之上的應用程序利用這套 API,就可以與互聯網上的另一台計算機中的程序、同一台計算機中的其他程序,甚至同一個程序中的其他線程進行通信。
例如,在 Linux 操作系統中,用於創建 socket 實例的 API,就是由一個名為socket的系統調用代表的。這個系統調用是 Linux 內核的一部分。
所謂的系統調用,你可以理解為特殊的 C 語言函數。它們是連接應用程序和操作系統內核的橋梁,也是應用程序使用操作系統功能的唯一渠道。
在 Go 語言標准庫的syscall代碼包中,有一個與這個socket系統調用相對應的函數。這兩者的函數簽名是基本一致的,它們都會接受三個int類型的參數,並會返回一個可以代表文件描述符的結果。
但不同的是,syscall包中的Socket函數本身是平台不相關的。在其底層,Go 語言為它支持的每個操作系統都做了適配,這才使得這個函數無論在哪個平台上,總是有效的。
package main
import (
"fmt"
"syscall"
)
func main() {
fd1, err := syscall.Socket(
syscall.AF_INET, syscall.SOCK_STREAM, syscall.IPPROTO_TCP)
if err != nil {
fmt.Printf("socket error: %v\n", err)
return
}
defer syscall.Close(fd1)
fmt.Printf("The file descriptor of socket:%d\n", fd1)
// 省略若干代碼。
// 如果真要完全使用syscall包中的程序實體建立網絡連接的話,
// 過程太過繁瑣而且完全沒有必要。
// 所以,我在這里就不做展示了。
}
Go 語言的net代碼包中的很多程序實體,都會直接或間接地使用到syscall.Socket函數。
比如,我們在調用net.Dial函數的時候,會為它的兩個參數設定值。其中的第一個參數名為network,它決定着 Go 程序在底層會創建什么樣的 socket 實例,並使用什么樣的協議與其他程序通信。
下面,我們就通過一個簡單的問題來看看怎樣正確地調用net.Dial函數。
今天的問題是:net.Dial函數的第一個參數network有哪些可選值?
這道題的典型回答是這樣的。
net.Dial函數會接受兩個參數,分別名為network和address,都是string類型的。
參數network常用的可選值一共有 9 個。這些值分別代表了程序底層創建的 socket 實例可使用的不同通信協議,羅列如下。
- "tcp":代表 TCP 協議,其基於的 IP 協議的版本根據參數address的值自適應。
- "tcp4":代表基於 IP 協議第四版的 TCP 協議。
- "tcp6":代表基於 IP 協議第六版的 TCP 協議。
- "udp":代表 UDP 協議,其基於的 IP 協議的版本根據參數address的值自適應。
- "udp4":代表基於 IP 協議第四版的 UDP 協議。
- "udp6":代表基於 IP 協議第六版的 UDP 協議。
- "unix":代表 Unix 通信域下的一種內部 socket 協議,以 SOCK_STREAM 為 socket 類型。
- "unixgram":代表 Unix 通信域下的一種內部 socket 協議,以 SOCK_DGRAM 為 socket 類型。
- "unixpacket":代表 Unix 通信域下的一種內部 socket 協議,以 SOCK_SEQPACKET 為 socket 類型。
問題解析
為了更好地理解這些可選值的深層含義,我們需要了解一下syscall.Socket函數接受的那三個參數。
我在前面說了,這個函數接受的三個參數都是int類型的。這些參數所代表的分別是想要創建的 socket 實例通信域、類型以及使用的協議。
Socket 的通信域主要有這樣幾個可選項:IPv4 域、IPv6 域和 Unix 域。
我想你應該能夠猜出 IPv4 域、IPv6 域的含義,它們對應的分別是基於 IP 協議第四版的網絡,和基於 IP 協議第六版的網絡。
現在的計算機網絡大都是基於 IP 協議第四版的,但是由於現有 IP 地址的逐漸枯竭,網絡世界也在逐步地支持 IP 協議第六版。
Unix 域,指的是一種類 Unix 操作系統中特有的通信域。在裝有此類操作系統的同一台計算機中,應用程序可以基於此域建立 socket 連接。
以上三種通信域分別可以由syscall代碼包中的常量AF_INET、AF_INET6和AF_UNIX表示。
Socket 的類型一共有 4 種,分別是:SOCK_DGRAM、SOCK_STREAM、SOCK_SEQPACKET以及SOCK_RAW。
syscall代碼包中也都有同名的常量與之對應。前兩者更加常用一些。SOCK_DGRAM中的“DGRAM”代表的是 datagram,即數據報文。它是一種有消息邊界,但沒有邏輯連接的非可靠 socket 類型,我們熟知的基於 UDP 協議的網絡通信就屬於此類。
有消息邊界的意思是,與 socket 相關的操作系統內核中的程序(以下簡稱內核程序)在發送或接收數據的時候是以消息為單位的。
你可以把消息理解為帶有固定邊界的一段數據。內核程序可以自動地識別和維護這種邊界,並在必要的時候,把數據切割成一個一個的消息,或者把多個消息串接成連續的數據。如此一來,應用程序只需要面向消息進行處理就可以了。
所謂的有邏輯連接是指,通信雙方在收發數據之前必須先建立網絡連接。待連接建立好之后,雙方就可以一對一地進行數據傳輸了。顯然,基於 UDP 協議的網絡通信並不需要這樣,它是沒有邏輯連接的。
只要應用程序指定好對方的網絡地址,內核程序就可以立即把數據報文發送出去。這有優勢,也有劣勢。
優勢是發送速度快,不長期占用網絡資源,並且每次發送都可以指定不同的網絡地址。
當然了,最后一個優勢有時候也是劣勢,因為這會使數據報文更長一些。其他的劣勢有,無法保證傳輸的可靠性,不能實現數據的有序性,以及數據只能單向進行傳輸。
而SOCK_STREAM這個 socket 類型,恰恰與SOCK_DGRAM相反。它沒有消息邊界,但有邏輯連接,能夠保證傳輸的可靠性和數據的有序性,同時還可以實現數據的雙向傳輸。眾所周知的基於 TCP 協議的網絡通信就屬於此類。
這樣的網絡通信傳輸數據的形式是字節流,而不是數據報文。字節流是以字節為單位的。內核程序無法感知一段字節流中包含了多少個消息,以及這些消息是否完整,這完全需要應用程序自己去把控。
不過,此類網絡通信中的一端,總是會忠實地按照另一端發送數據時的字節排列順序,接收和緩存它們。所以,應用程序需要根據雙方的約定去數據中查找消息邊界,並按照邊界切割數據,僅此而已。
syscall.Socket函數的第三個參數用於表示 socket 實例所使用的協議。
通常,只要明確指定了前兩個參數的值,我們就無需再去確定第三個參數值了,一般把它置為0就可以了。這時,內核程序會自行選擇最合適的協議。
比如,當前兩個參數值分別為syscall.AF_INET和syscall.SOCK_DGRAM的時候,內核程序會選擇 UDP 作為協議。
又比如,在前兩個參數值分別為syscall.AF_INET6和syscall.SOCK_STREAM時,內核程序可能會選擇 TCP 作為協議。
(syscall.Socket 函數一瞥)
不過,你也看到了,在使用net包中的高層次 API 的時候,我們連那前兩個參數值都無需給定,只需要把前面羅列的那些字符串字面量的其中一個,作為network參數的值就好了。
當然,如果你在使用這些 API 的時候,能夠想到我在上面說的這些基礎知識的話,那么一定會對你做出正確的判斷和選擇有所幫助。
package main
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"runtime"
)
func main() {
network := "tcp"
host := "google.cn"
reqStrTpl := `HEAD / HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: %s
User-Agent: Dialer/%s
`
// 示例1。
network1 := network + "4"
address1 := host + ":80"
fmt.Printf("Dial %q with network %q ...\n", address1, network1)
conn1, err := net.Dial(network1, address1)
if err != nil {
fmt.Printf("dial error: %v\n", err)
return
}
defer conn1.Close()
reqStr1 := fmt.Sprintf(reqStrTpl, host, runtime.Version())
fmt.Printf("The request:\n%s\n", reqStr1)
_, err = io.WriteString(conn1, reqStr1)
if err != nil {
fmt.Printf("write error: %v\n", err)
return
}
fmt.Println()
reader1 := bufio.NewReader(conn1)
line1, err := reader1.ReadString('\n')
if err != nil {
fmt.Printf("read error: %v\n", err)
return
}
fmt.Printf("The first line of response:\n%s\n", line1)
fmt.Println()
// 示例2。
tlsConf := &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS10,
}
network2 := network
address2 := host + ":443"
fmt.Printf("Dial %q with network %q ...\n", address2, network2)
conn2, err := tls.Dial(network2, address2, tlsConf)
if err != nil {
fmt.Printf("dial error: %v\n", err)
return
}
defer conn2.Close()
reqStr2 := fmt.Sprintf(reqStrTpl, host, runtime.Version())
fmt.Printf("The request:\n%s\n", reqStr2)
_, err = io.WriteString(conn2, reqStr2)
if err != nil {
fmt.Printf("write error: %v\n", err)
return
}
reader2 := bufio.NewReader(conn2)
line2, err := reader2.ReadString('\n')
if err != nil {
fmt.Printf("read error: %v\n", err)
return
}
fmt.Printf("The first line of response:\n%s\n", line2)
fmt.Println()
}
知識擴展
問題 1:調用net.DialTimeout函數時給定的超時時間意味着什么?
簡單來說,這里的超時時間,代表着函數為網絡連接建立完成而等待的最長時間。這是一個相對的時間。它會由這個函數的參數timeout的值表示。
開始的時間點幾乎是我們調用net.DialTimeout函數的那一刻。在這之后,時間會主要花費在“解析參數network和address的值”,以及“創建 socket 實例並建立網絡連接”這兩件事情上。
不論執行到哪一步,只要在絕對的超時時間達到的那一刻,網絡連接還沒有建立完成,該函數就會返回一個代表了 I/O 操作超時的錯誤值。
值得注意的是,在解析address的值的時候,函數會確定網絡服務的 IP 地址、端口號等必要信息,並在需要時訪問 DNS 服務。
另外,如果解析出的 IP 地址有多個,那么函數會串行或並發地嘗試建立連接。但無論用什么樣的方式嘗試,函數總會以最先建立成功的那個連接為准。
同時,它還會根據超時前的剩余時間,去設定針對每次連接嘗試的超時時間,以便讓它們都有適當的時間執行。
再多說一點。在net包中還有一個名為Dialer的結構體類型。該類型有一個名叫Timeout的字段,它與上述的timeout參數的含義是完全一致的。實際上,net.DialTimeout函數正是利用了這個類型的值才得以實現功能的。
net.Dialer類型值得你好好學習一下,尤其是它的每個字段的功用以及它的DialContext方法。
package main
import (
"fmt"
"net"
"time"
)
type dailArgs struct {
network string
address string
timeout time.Duration
}
func main() {
dialArgsList := []dailArgs{
{
"tcp",
"google.cn:80",
time.Millisecond * 500,
},
{
"tcp",
"google.com:80",
time.Second * 2,
},
{
// 如果在這種情況下發生的錯誤是:
// "connect: operation timed out",
// 那么代表着什么呢?
//
// 簡單來說,此錯誤表示底層的socket在連接網絡服務的時候先超時了。
// 這時拋出的其實是'syscall.ETIMEDOUT'常量代表的錯誤值。
"tcp",
"google.com:80",
time.Minute * 4,
},
}
for _, args := range dialArgsList {
fmt.Printf("Dial %q with network %q and timeout %s ...\n",
args.address, args.network, args.timeout)
ts1 := time.Now()
conn, err := net.DialTimeout(args.network, args.address, args.timeout)
ts2 := time.Now()
fmt.Printf("Elapsed time: %s\n", time.Duration(ts2.Sub(ts1)))
if err != nil {
fmt.Printf("dial error: %v\n", err)
fmt.Println()
continue
}
defer conn.Close()
fmt.Printf("The local address: %s\n", conn.LocalAddr())
fmt.Printf("The remote address: %s\n", conn.RemoteAddr())
fmt.Println()
}
}
總結
我們今天提及了使用 Go 語言進行網絡編程這個主題。作為引子,我先向你介紹了關於 socket 的一些基礎知識。socket 常被翻譯為套接字,它是一種 IPC 方法。IPC 可以被翻譯為進程間通信,它主要定義了多個進程之間相互通信的方法。
Socket 是 IPC 方法中最為通用和靈活的一種。與其他的方法不同,利用 socket 進行通信的進程可以不局限在同一台計算機當中。
只要通信的雙方能夠通過計算機的網卡端口,以及網絡進行互聯就可以使用 socket,無論它們存在於世界上的哪個角落。
支持 socket 的操作系統一般都會對外提供一套 API。Go 語言的syscall代碼包中也有與之對應的程序實體。其中最重要的一個就是syscall.Socket函數。
不過,syscall包中的這些程序實體,對於普通的 Go 程序來說都屬於底層的東西了,我們通常很少會用到。一般情況下,我們都會使用net代碼包及其子包中的 API 去編寫網絡程序。
net包中一個很常用的函數,名為Dial。這個函數主要用於連接網絡服務。它會接受兩個參數,你需要搞明白這兩個參數的值都應該怎么去設定。
尤其是network參數,它有很多的可選值,其中最常用的有 9 個。這些可選值的背后都代表着相應的 socket 屬性,包括通信域、類型以及使用的協議。一旦你理解了這些 socket 屬性,就一定會幫助你做出正確的判斷和選擇。
與此相關的一個函數是net.DialTimeout。我們在調用它的時候需要設定一個超時時間。這個超時時間的含義你是需要搞清楚的。
通過它,我們可以牽扯出這個函數的一大堆實現細節。另外,還有一個叫做net.Dialer的結構體類型。這個類型其實是前述兩個函數的底層實現,值得你好好地學習一番。
以上,就是我今天講的主要內容,它們都是關於怎樣訪問網絡服務的。你可以從這里入手,進入 Go 語言的網絡編程世界。
思考題
今天的思考題也與超時時間有關。在你調用了net.Dial等函數之后,如果成功就會得到一個代表了網絡連接的net.Conn接口類型的值。我的問題是:怎樣在net.Conn類型的值上正確地設定針對讀操作和寫操作的超時時間?
筆記源碼
https://github.com/MingsonZheng/go-core-demo
本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。