Go的網路程式設計詳解

2022-10-09 21:01:21

一 網際網路協定介紹

1.1網際網路分層模型

網際網路的邏輯實現被分為好幾層。每一層都有自己的功能,就像建築物一樣,每一層都靠下一層支援。使用者接觸到的只是最上面的那一層,根本不會感覺到下面的幾層。要理解網際網路就需要自下而上理解每一層的實現的功能。

如上圖所示,網際網路按照不同的模型劃分會有不用的分層,但是不論按照什麼模型去劃分,越往上的層越靠近使用者,越往下的層越靠近硬體。在軟體開發中我們使用最多的是上圖中將網際網路劃分為五個分層的模型。

接下來我們一層一層的自底向上介紹一下每一層。

物理層

我們的電腦要與外界網際網路通訊,需要先把電腦連線網路,我們可以用雙絞線、光纖、無線電波等方式。這就叫做」實物理層」,它就是把電腦連線起來的物理手段。它主要規定了網路的一些電氣特性,作用是負責傳送0和1的電訊號。

資料鏈路層

單純的0和1沒有任何意義,所以我們使用者會為其賦予一些特定的含義,規定解讀電訊號的方式:例如:多少個電訊號算一組?每個訊號位有何意義?這就是」資料連結層」的功能,它在」物理層」的上方,確定了物理層傳輸的0和1的分組方式及代表的意義。早期的時候,每家公司都有自己的電訊號分組方式。逐漸地,一種叫做」乙太網」(Ethernet)的協定,佔據了主導地位。

乙太網規定,一組電訊號構成一個封包,叫做」幀」(Frame)。每一幀分成兩個部分:檔頭(Head)和資料(Data)。其中」檔頭」包含封包的一些說明項,比如傳送者、接受者、資料型別等等;」資料」則是封包的具體內容。」檔頭」的長度,固定為18位元組。」資料」的長度,最短為46位元組,最長為1500位元組。因此,整個」幀」最短為64位元組,最長為1518位元組。如果資料很長,就必須分割成多個幀進行傳送。

那麼,傳送者和接受者是如何標識呢?乙太網規定,連入網路的所有裝置都必須具有」網路卡」介面。封包必須是從一塊網路卡,傳送到另一塊網路卡。網路卡的地址,就是封包的傳送地址和接收地址,這叫做MAC地址。每塊網路卡出廠的時候,都有一個全世界獨一無二的MAC地址,長度是48個二進位制位,通常用12個十六進位制數表示。前6個十六進位制數是廠商編號,後6個是該廠商的網路卡流水號。有了MAC地址,就可以定位網路卡和封包的路徑了。

我們會通過ARP協定來獲取接受方的MAC地址,有了MAC地址之後,如何把資料準確的傳送給接收方呢?其實這裡乙太網採用了一種很」原始」的方式,它不是把封包準確送到接收方,而是向本網路內所有計算機都傳送,讓每臺計算機讀取這個包的」檔頭」,找到接收方的MAC地址,然後與自身的MAC地址相比較,如果兩者相同,就接受這個包,做進一步處理,否則就丟棄這個包。這種傳送方式就叫做」廣播」(broadcasting)。

網路層

按照乙太網協定的規則我們可以依靠MAC地址來向外傳送資料。理論上依靠MAC地址,你電腦的網路卡就可以找到身在世界另一個角落的某臺電腦的網路卡了,但是這種做法有一個重大缺陷就是乙太網採用廣播方式傳送封包,所有成員人手一」包」,不僅效率低,而且傳送的資料只能侷限在傳送者所在的子網路。也就是說如果兩臺計算機不在同一個子網路,廣播是傳不過去的。這種設計是合理且必要的,因為如果網際網路上每一臺計算機都會收到網際網路上收發的所有封包,那是不現實的。

因此,必須找到一種方法區分哪些MAC地址屬於同一個子網路,哪些不是。如果是同一個子網路,就採用廣播方式傳送,否則就採用」路由」方式傳送。這就導致了」網路層」的誕生。它的作用是引進一套新的地址,使得我們能夠區分不同的計算機是否屬於同一個子網路。這套地址就叫做」網路地址」,簡稱」網址」。

「網路層」出現以後,每臺計算機有了兩種地址,一種是MAC地址,另一種是網路地址。兩種地址之間沒有任何聯絡,MAC地址是繫結在網路卡上的,網路地址則是網路管理員分配的。網路地址幫助我們確定計算機所在的子網路,MAC地址則將封包送到該子網路中的目標網路卡。因此,從邏輯上可以推斷,必定是先處理網路地址,然後再處理MAC地址。

規定網路地址的協定,叫做IP協定。它所定義的地址,就被稱為IP地址。目前,廣泛採用的是IP協定第四版,簡稱IPv4。IPv4這個版本規定,網路地址由32個二進位制位組成,我們通常習慣用分成四段的十進位制數表示IP地址,從0.0.0.0一直到255.255.255.255。

根據IP協定傳送的資料,就叫做IP封包。IP封包也分為」檔頭」和」資料」兩個部分:」檔頭」部分主要包括版本、長度、IP地址等資訊,」資料」部分則是IP封包的具體內容。IP封包的」檔頭」部分的長度為20到60位元組,整個封包的總長度最大為65535位元組。

傳輸層

有了MAC地址和IP地址,我們已經可以在網際網路上任意兩臺主機上建立通訊。但問題是同一臺主機上會有許多程式都需要用網路收發資料,比如QQ和瀏覽器這兩個程式都需要連線網際網路並收發資料,我們如何區分某個封包到底是歸哪個程式的呢?也就是說,我們還需要一個引數,表示這個封包到底供哪個程式(程序)使用。這個引數就叫做」埠」(port),它其實是每一個使用網路卡的程式的編號。每個封包都發到主機的特定埠,所以不同的程式就能取到自己所需要的資料。

「埠」是0到65535之間的一個整數,正好16個二進位制位。0到1023的埠被系統佔用,使用者只能選用大於1023的埠。有了IP和埠我們就能實現唯一確定網際網路上一個程式,進而實現網路間的程式通訊。

我們必須在封包中加入埠資訊,這就需要新的協定。最簡單的實現叫做UDP協定,它的格式幾乎就是在資料前面,加上埠號。UDP封包,也是由」檔頭」和」資料」兩部分組成:」檔頭」部分主要定義了發出埠和接收埠,」資料」部分就是具體的內容。UDP封包非常簡單,」檔頭」部分一共只有8個位元組,總長度不超過65,535位元組,正好放進一個IP封包。

UDP協定的優點是比較簡單,容易實現,但是缺點是可靠性較差,一旦封包發出,無法知道對方是否收到。為了解決這個問題,提高網路可靠性,TCP協定就誕生了。TCP協定能夠確保資料不會遺失。它的缺點是過程複雜、實現困難、消耗較多的資源。TCP封包沒有長度限制,理論上可以無限長,但是為了保證網路的效率,通常TCP封包的長度不會超過IP封包的長度,以確保單個TCP封包不必再分割。

應用層

應用程式收到」傳輸層」的資料,接下來就要對資料進行解包。由於網際網路是開放架構,資料來源五花八門,必須事先規定好通訊的資料格式,否則接收方根本無法獲得真正傳送的資料內容。」應用層」的作用就是規定應用程式使用的資料格式,例如我們TCP協定之上常見的Email、HTTP、FTP等協定,這些協定就組成了網際網路協定的應用層。

如下圖所示,傳送方的HTTP資料經過網際網路的傳輸過程中會依次新增各層協定的檔頭資訊,接收方收到封包之後再依次根據協定解包得到資料。

二 socket程式設計

Socket是BSD UNIX的程序通訊機制,通常也稱作」通訊端」,用於描述IP地址和埠,是一個通訊鏈的控制程式碼。Socket可以理解為TCP/IP網路的API,它定義了許多函數或例程,程式設計師可以用它們來開發TCP/IP網路上的應用程式。電腦上執行的應用程式通常通過」通訊端」向網路發出請求或者應答網路請求。

socket圖解

Socket是應用層與TCP/IP協定族通訊的中間軟體抽象層。在設計模式中,Socket其實就是一個門面模式,它把複雜的TCP/IP協定族隱藏在Socket後面,對使用者來說只需要呼叫Socket規定的相關函數,讓Socket去組織符合指定的協定資料然後進行通訊。

  • Socket又稱「通訊端」,應用程式通常通過「通訊端」向網路發出請求或者應答網路請求
  • 常用的Socket型別有兩種:流式Socket和資料包式Socket,流式是一種面向連線的Socket,針對於面向連線的TCP服務應用,資料包式Socket是一種無連線的Socket,針對於無連線的UDP服務應用
  • TCP:比較靠譜,面向連線,比較慢
  • UDP:不是太靠譜,比較快

舉個例子:TCP就像貨到付款的快遞,送到家還必須見到你人才算一整套流程。UDP就像某快遞快遞櫃一扔就走管你收到收不到,一般直播用UDP。

三 TCP程式設計

Go語言實現TCP通訊

TCP協定

TCP/IP(Transmission Control Protocol/Internet Protocol) 即傳輸控制協定/網間協定,是一種面向連線(連線導向)的、可靠的、基於位元組流的傳輸層(Transport layer)通訊協定,因為是面向連線的協定,資料像水流一樣傳輸,會存在黏包問題。

TCP伺服器端

一個TCP伺服器端可以同時連線很多個使用者端,例如世界各地的使用者使用自己電腦上的瀏覽器存取淘寶網。因為Go語言中建立多個goroutine實現並行非常方便和高效,所以我們可以每建立一次連結就建立一個goroutine去處理。

TCP伺服器端程式的處理流程:

    1.監聽埠
    2.接收使用者端請求建立連結
    3.建立goroutine處理連結。

我們使用Go語言的net包實現的TCP伺服器端程式碼如下:

// tcp/server/main.go

// TCP server端

// 處理常式
func process(conn net.Conn) {
    defer conn.Close() // 關閉連線
    for {
        reader := bufio.NewReader(conn)
        var buf [128]byte
        n, err := reader.Read(buf[:]) // 讀取資料
        if err != nil {
            fmt.Println("read from client failed, err:", err)
            break
        }
        recvStr := string(buf[:n])
        fmt.Println("收到client端發來的資料:", recvStr)
        conn.Write([]byte(recvStr)) // 傳送資料
    }
}

func main() {
    listen, err := net.Listen("tcp", "127.0.0.1:20000")
    if err != nil {
        fmt.Println("listen failed, err:", err)
        return
    }
    for {
        conn, err := listen.Accept() // 建立連線
        if err != nil {
            fmt.Println("accept failed, err:", err)
            continue
        }
        go process(conn) // 啟動一個goroutine處理連線
    }
}

將上面的程式碼儲存之後編譯成server或server.exe可執行檔案。

TCP使用者端

一個TCP使用者端進行TCP通訊的流程如下:

    1.建立與伺服器端的連結
    2.進行資料收發
    3.關閉連結

使用Go語言的net包實現的TCP使用者端程式碼如下:

// tcp/client/main.go

// 使用者端
func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:20000")
    if err != nil {
        fmt.Println("err :", err)
        return
    }
    defer conn.Close() // 關閉連線
    inputReader := bufio.NewReader(os.Stdin)
    for {
        input, _ := inputReader.ReadString('\n') // 讀取使用者輸入
        inputInfo := strings.Trim(input, "\r\n")
        if strings.ToUpper(inputInfo) == "Q" { // 如果輸入q就退出
            return
        }
        _, err = conn.Write([]byte(inputInfo)) // 傳送資料
        if err != nil {
            return
        }
        buf := [512]byte{}
        n, err := conn.Read(buf[:])
        if err != nil {
            fmt.Println("recv failed, err:", err)
            return
        }
        fmt.Println(string(buf[:n]))
    }
}

將上面的程式碼編譯成client或client.exe可執行檔案,先啟動server端再啟動client端,在client端輸入任意內容回車之後就能夠在server端看到client端傳送的資料,從而實現TCP通訊。

四 UDP程式設計

Go語言實現UDP通訊

UDP協定

UDP協定(User Datagram Protocol)中文名稱是使用者資料包協定,是OSI(Open System Interconnection,開放式系統互聯)參考模型中一種無連線的傳輸層協定,不需要建立連線就能直接進行資料傳送和接收,屬於不可靠的、沒有時序的通訊,但是UDP協定的實時性比較好,通常用於視訊直播相關領域。

UDP伺服器端

使用Go語言的net包實現的UDP伺服器端程式碼如下:

// UDP/server/main.go

// UDP server端
func main() {
    listen, err := net.ListenUDP("udp", &net.UDPAddr{
        IP:   net.IPv4(0, 0, 0, 0),
        Port: 30000,
    })
    if err != nil {
        fmt.Println("listen failed, err:", err)
        return
    }
    defer listen.Close()
    for {
        var data [1024]byte
        n, addr, err := listen.ReadFromUDP(data[:]) // 接收資料
        if err != nil {
            fmt.Println("read udp failed, err:", err)
            continue
        }
        fmt.Printf("data:%v addr:%v count:%v\n", string(data[:n]), addr, n)
        _, err = listen.WriteToUDP(data[:n], addr) // 傳送資料
        if err != nil {
            fmt.Println("write to udp failed, err:", err)
            continue
        }
    }
}

UDP使用者端

使用Go語言的net包實現的UDP使用者端程式碼如下:

// UDP 使用者端
func main() {
    socket, err := net.DialUDP("udp", nil, &net.UDPAddr{
        IP:   net.IPv4(0, 0, 0, 0),
        Port: 30000,
    })
    if err != nil {
        fmt.Println("連線伺服器端失敗,err:", err)
        return
    }
    defer socket.Close()
    sendData := []byte("Hello server")
    _, err = socket.Write(sendData) // 傳送資料
    if err != nil {
        fmt.Println("傳送資料失敗,err:", err)
        return
    }
    data := make([]byte, 4096)
    n, remoteAddr, err := socket.ReadFromUDP(data) // 接收資料
    if err != nil {
        fmt.Println("接收資料失敗,err:", err)
        return
    }
    fmt.Printf("recv:%v addr:%v count:%v\n", string(data[:n]), remoteAddr, n)
}

五 http程式設計

web工作流程

Web伺服器的工作原理可以簡單地歸納為:

  • 客戶機通過TCP/IP協定建立到伺服器的TCP連線
  • 使用者端向伺服器傳送HTTP協定請求包,請求伺服器裡的資原始檔
  • 伺服器向客戶機傳送HTTP協定應答包,如果請求的資源包含有動態語言的內容,那麼伺服器會呼叫動態語言的解釋引擎負責處理「動態內容」,並將處理得到的資料返回給使用者端
  • 客戶機與伺服器斷開。由使用者端解釋HTML檔案,在使用者端螢幕上渲染圖形結果

HTTP協定

超文字傳輸協定(HTTP,HyperText Transfer Protocol)是網際網路上應用最為廣泛的一種網路協定,它詳細規定了瀏覽器和全球資訊網伺服器之間互相通訊的規則,通過因特網傳送全球資訊網檔案的資料傳送協定
HTTP協定通常承載於TCP協定之上

HTTP伺服器端

package main

import (
    "fmt"
    "net/http"
)

func main() {
    //http://127.0.0.1:8000/go
    // 單獨寫回撥函數
    http.HandleFunc("/go", myHandler)
    //http.HandleFunc("/ungo",myHandler2 )
    // addr:監聽的地址
    // handler:回撥函數
    http.ListenAndServe("127.0.0.1:8000", nil)
}

// handler函數
func myHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Println(r.RemoteAddr, "連線成功")
    // 請求方式:GET POST DELETE PUT UPDATE
    fmt.Println("method:", r.Method)
    // /go
    fmt.Println("url:", r.URL.Path)
    fmt.Println("header:", r.Header)
    fmt.Println("body:", r.Body)
    // 回覆
    w.Write([]byte("www.5lmh.com"))
}

HTTP伺服器端

package main

import (
    "fmt"
    "io"
    "net/http"
)

func main() {
    //resp, _ := http.Get("http://www.baidu.com")
    //fmt.Println(resp)
    resp, _ := http.Get("http://127.0.0.1:8000/go")
    defer resp.Body.Close()
    // 200 OK
    fmt.Println(resp.Status)
    fmt.Println(resp.Header)

    buf := make([]byte, 1024)
    for {
        // 接收伺服器端資訊
        n, err := resp.Body.Read(buf)
        if err != nil && err != io.EOF {
            fmt.Println(err)
            return
        } else {
            fmt.Println("讀取完畢")
            res := string(buf[:n])
            fmt.Println(res)
            break
        }
    }
}

六 WebSocket程式設計

webSocket是什麼

  • WebSocket是一種在單個TCP連線上進行全雙工通訊的協定
  • WebSocket使得使用者端和伺服器之間的資料交換變得更加簡單,允許伺服器端主動向使用者端推播資料
  • 在WebSocket API中,瀏覽器和伺服器只需要完成一次握手,兩者之間就直接可以建立永續性的連線,並進行雙向資料傳輸
  • 需要安裝第三方包:
    cmd中:go get -u -v github.com/gorilla/websocket

我們啟動一個http伺服器,指定根路徑路由到一個html頁面,該頁面用來模擬websocket通訊的使用者端,頁面會提供一個按鈕觸發一段執行websocket通訊的js。伺服器端接收到websocket請求,然後將請求的內容完整地響應給瀏覽器。

接收websocket請求的伺服器端:

package main

import (
	"fmt"
	"net/http"

	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
}

func main() {
	http.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) {
		conn, _ := upgrader.Upgrade(w, r, nil) // error ignored for sake of simplicity

		for {
			// Read message from browser
			msgType, msg, err := conn.ReadMessage()
			if err != nil {
				return
			}

			// Print the message to the console
			fmt.Printf("%s sent: %s\n", conn.RemoteAddr(), string(msg))

			// Write message back to browser
			if err = conn.WriteMessage(msgType, msg); err != nil {
				return
			}
		}
	})

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		http.ServeFile(w, r, "H:\\go\\main\\websockets.html")
	})

	http.ListenAndServe(":8080", nil)
}

傳送websocket請求的使用者端:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebSockets</title>
</head>
<body>
<input id="input" type="text" />
<button onclick="send()">Send</button>
<pre id="output"></pre>
<script>
    var input = document.getElementById("input");
    var output = document.getElementById("output");
    var socket = new WebSocket("ws://localhost:8080/echo");

    socket.onopen = function () {
        output.innerHTML += "Status: Connected\n";
    };

    socket.onmessage = function (e) {
        output.innerHTML += "Server: " + e.data + "\n";
    };

    function send() {
        socket.send(input.value);
        input.value = "";
    }
</script>
</body>
</html>