Go語言核心36講(Go語言實戰與應用二十四)--學習筆記


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 作為協議。

image

(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/ ),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM