GO的網路程式設計分享

阿兵雲原生發表於2021-06-07
[TOC]

回顧一下我們上次分享的網路協議5層模型

  • 物理層
  • 資料鏈路層
  • 網路層
  • 傳輸層
  • 應用層

每一層有每一層的獨立功能,大多數網路都採用分層的體系結構,每一層都建立在它的下層之上,向它的上一層提供一定的服務,而把如何實現這一服務的細節對上一層加以遮蔽

每一層背後的協議有哪些,具體有啥為什麼出現的,感興趣的可以看看網際網路協議知多少

瞭解了網路協議的分層,資料包是如何封包,如何拆包,如何得到源資料的,往下看心裡就有點譜了

GO網路程式設計指的是什麼?

GO網路程式設計,這裡是指的是SOCKET程式設計

相信寫過c/c++網路程式設計的朋友看到這裡並不陌生吧,我們再來回顧一下

網路程式設計這一塊,分為客戶端部分的開發,和服務端部分的開發,會涉及到相應的關鍵流程

服務端涉及的流程

  • socket建立套接字
  • bind繫結地址和埠
  • listen設定最大監聽數
  • accept開始阻塞等待客戶端的連線
  • read讀取資料
  • write回寫資料
  • close 關閉

客戶端涉及的流程

  • socket建立套接字
  • connect 連線服務端
  • write寫資料
  • read讀取資料

我們來看看SOCKET程式設計是啥?

SOCKET就是套接字,是BSD UNIX的程式通訊機制,他是一個控制程式碼,用於描述IP地址的。

當然SOCKET也是可以理解為TCP/IP網路API(應用程式介面)SOCKET定義了許多函式,我們可以用它們來開發TCP/IP網路上的應用程式。

電腦上執行的應用程式通常通過SOCKET向網路發出請求或者應答網路請求。

哈,突然想到面向介面程式設計

顧名思義,就是在一個物件導向的系統中,系統的各種功能是由許許多多的不同物件協作完成的。

在這種情況下,各個物件內部是如何實現自己的,對系統設計人員來講就不那麼重要了

各個物件之間的協作關係則成為系統設計的關鍵,面向介面程式設計的知道思想就是,無論模組大小,對應模組之間的互動都必須要在系統設計的時候著重考慮。

哈,一味的依賴別人提供的介面,關於介面內部會不會有坑,為什麼會失敗,我們就不得而知了

開始socket程式設計

先上一張圖,我們一起瞅瞅

Socket是應用層與TCP/IP協議族通訊的中間軟體抽象層

在設計模式中,Socket其實就是一個門面模式,它把複雜的TCP/IP協議族隱藏在Socket後面

對使用者來說只需要呼叫Socket規定的相關函式就可以了,讓Socket去做剩下的事情

Socket,應用程式通常通過Socket向網路發出請求 / 應答網路請求

常用的Socket型別有2種

  • 流式Socket(stream)

流式是一種面向連線Socket,針對於面向連線的TCP服務應用

  • 資料包式Socket

資料包式Socket是一種無連線的Socket,針對於無連線的UDP服務應用

簡單對比一下:

  • TCP:比較靠譜,面向連線,安全,可靠的傳輸方式 , 但是 比較慢

  • UDP: 不是太靠譜,不可靠的,丟包不會重傳,但是 比較快

舉一個現在生活中最常見的例子:

案例一

別人買一個小禮物給你,還要貨到付款,這個時候快遞員將貨送到你家的時候,必須看到你人,而且你要付錢,這才是完成了一個流程 , 這是TCP

案例二

還是快遞的例子,比如你在網上隨便搶了一些不太重要的小東西,小玩意,快遞員送貨的時候,直接就把你的包括扔到某個快遞點,頭都不回一下的那種, 這是UDP

網路程式設計無非簡單來看就是TCP程式設計UDP程式設計

我們一起來看看GOLANG如何實現基於TCP通訊 和 基於UDP通訊的

GO基於TCP程式設計

那我們先來看看TCP協議是個啥?

TCP/IP(Transmission Control Protocol/Internet Protocol)

傳輸控制協議/網間協議,是一種面向連線(連線導向)的、可靠的基於位元組流傳輸層(Transport layer)通訊協議

因為是面向連線的協議,資料像水流一樣傳輸,這樣會產生黏包問題。

上述提了一般socket程式設計的服務端流程和客戶端流程,實際上go的底層實現也離不開這幾步,但是我們從應用的角度來看看go的TCP程式設計,服務端有哪些流程

TCP服務端

TCP服務端可以同時連線很多個客戶端,這個毋庸置疑,要是一個服務端只能接受一個客戶端的連線,那麼你完了,你可以收拾東西回家了

舉個例子

最近也要開始的各種瘋狂購物活動,他們的服務端,全球各地的客戶端都會去連線,那麼TCP服務端又是如何處理的嘞,在C/C++中我們會基於epoll模型來進行處理,來一個客戶端的連線/請求事件,我們就專門開一個執行緒去進行處理

那麼golang中是如何處理的呢?

golang中,每建立一個連線,就會開闢一個協程goroutine來處理這個請求

服務端處理流程大致分為如下幾步

  • 監聽埠
  • 接收客戶端請求建立連結
  • 建立goroutine處理連結
  • 關閉

能做大這麼簡潔和友好的處理方式,得益於Go中的net包

TCP服務端的具體實現:

func process(conn net.Conn) {
    // 關閉連線
    defer conn.Close() 
    for {
        reader := bufio.NewReader(conn)
        var buf [256]byte
        // 讀取資料
        n, err := reader.Read(buf[:]) 
        if err != nil {
            fmt.Println("reader.Read  error : ", err)
            break
        }
        recvData := string(buf[:n])
        fmt.Println("receive data :", recvData)
        // 將資料再發給客戶端
        conn.Write([]byte(recvData)) 
    }
}

func main() {
    // 監聽tcp
    listen, err := net.Listen("tcp", "127.0.0.1:8888")
    if err != nil {
        fmt.Println("net.Listen  error : ", err)
        return
    }
    for {
        // 建立連線 , 看到這裡的朋友,有沒有覺得這裡和C/C++的做法一毛一樣
        conn, err := listen.Accept() 
        if err != nil {
            fmt.Println("listen.Accept error : ", err)
            continue
        }
        // 專門開一個goroutine去處理連線
        go process(conn) 
    }
}

TCP的服務端寫起來是不是很簡單呢

我們 看看TCP的客戶端

TCP客戶端

客戶端流程如下:

  • 與服務端建立連線
  • 讀寫資料
  • 關閉
func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:8888")
    if err != nil {
        fmt.Println("net.Dial error : ", err)
        return
    }
    // 關閉連線
    defer conn.Close() 
    // 鍵入資料
    inputReader := bufio.NewReader(os.Stdin)
    for {
        // 讀取使用者輸入
        input, _ := inputReader.ReadString('\n') 
        // 截斷
        inputInfo := strings.Trim(input, "\r\n")
        // 讀取到使用者輸入q 或者 Q 就退出
        if strings.ToUpper(inputInfo) == "Q" { 
            return
        }
        // 將輸入的資料傳送給服務端
        _, err = conn.Write([]byte(inputInfo)) 
        if err != nil {
            return
        }
        buf := [512]byte{}
        n, err := conn.Read(buf[:])
        if err != nil {
            fmt.Println("conn.Read error : ", err)
            return
        }
        fmt.Println(string(buf[:n]))
    }
}

注意事項

  • 服務端與客戶端聯調,需要先啟動服務端,等待客戶端的連線,

  • 若順序弄反,客戶端會因為找不到服務端而報錯

上面有說到TCP是流式協議,會存在黏包的問題,我們就來模擬一下,看看實際效果

TCP黏包如何解決?

來模擬寫一個服務端

server.go

package main

import (
   "bufio"
   "fmt"
   "io"
   "net"
)

// 專門處理客戶端連線
func process(conn net.Conn) {
   defer conn.Close()
   reader := bufio.NewReader(conn)
   var buf [2048]byte
   for {
      n, err := reader.Read(buf[:])
      // 如果客戶端關閉,則退出本協程
      if err == io.EOF {
         break
      }
      if err != nil {
         fmt.Println("reader.Read error :", err)
         break
      }
      recvStr := string(buf[:n])
      // 列印收到的資料,稍後我們主要是看這裡輸出的資料是否是我們期望的
      fmt.Printf("received data:%s\n\n", recvStr)
   }
}

func main() {

   listen, err := net.Listen("tcp", "127.0.0.1:8888")
   if err != nil {
      fmt.Println("net.Listen error : ", err)
      return
   }
   defer listen.Close()
   fmt.Println("server start ...  ")

   for {
      conn, err := listen.Accept()
      if err != nil {
         fmt.Println("listen.Accept error :", err)
         continue
      }
      go process(conn)
   }
}

寫一個客戶端進行配合

client.go

package main

import (
   "fmt"
   "net"
)

func main() {
   conn, err := net.Dial("tcp", "127.0.0.1:8888")
   if err != nil {
      fmt.Println("net.Dial error : ", err)
      return
   }
   defer conn.Close()
   fmt.Println("client start ... ")

   for i := 0; i < 30; i++ {

      msg := `Hello world, hello xiaomotong!`

      conn.Write([]byte(msg))
   }

   fmt.Println("send data over... ")

}

實際效果

server start ...
received data:Hello world, hello xiaomotong!Hello world, hello xiaomotong!

received data:Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello worl
d, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Helloworld, hello xiaomotong!

received data:Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!

received data:Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello world, hello xiaomotong!Hello worl
d, hello xiaomotong!Hello world, hello xiaomotong!

由上述效果我們可以看出來,客戶端傳送了30次資料給到服務端,可是服務端只輸出了4次,而是多條資料黏在了一起輸出了,這個現象就是黏包,那麼我們如何處理呢?

如何處理TCP黏包問題

黏包原因:

  • tcp資料傳遞模式是流式的,在保持長連線的時候可以進行多次的收和發

實際情況有如下2種

  • 由Nagle演算法造成的傳送端的粘包

Nagle演算法是一種改善網路傳輸效率的演算法

當我們提交一段資料給TCP傳送時,TCP並不會立刻傳送此段資料

而是等待一小段時間看看,在這段等待時間裡,是否還有要傳送的資料,若有則會一次把這兩段資料傳送出去

  • 接收端接收不及時造成的接收端粘包

TCP會把接收到的資料存在自己的緩衝區中,通知應用層取資料

當應用層由於某些原因不能及時的把TCP的資料取出來,就會造成TCP緩衝區中存放了幾段資料。

知道原因之後,我們來看看如何解決吧

開始解決TCP黏包問題

知道了黏包的原因,我們就針對原因下手就好了,分析一下,為什麼tcp會等一段時間,是不是因為tcp他不知道我們要傳送給他的資料包到底是多大,所以他就想盡可能的多吃點?

那麼,我們的解決方式就是 對資料包進行封包和拆包的操作。

  • 封包:

封包就是給一段資料加上包頭,這樣一來資料包就分為包頭和包體兩部分內容了,有時候為了過濾非法包,我們還會加上包尾。

包頭部分的長度是固定的,他會明確的指出包體的大小是多少,這樣子我們就可以正確的拆除一個完整的包了

  • 根據包頭長度固定
  • 根據包頭中含有包體長度的變數

我們可以自己定義一個協議,比如資料包的前2個位元組為包頭,裡面儲存的是傳送的資料的長度。

這一個自定義協議,客戶端和服務端都要知道,否則就沒得玩了

開始解決問題

server2.go

package main

import (
   "bufio"
   "bytes"
   "encoding/binary"
   "fmt"
   "io"
   "net"
)

// Decode 解碼訊息
func Decode(reader *bufio.Reader) (string, error) {
   // 讀取訊息的長度
   lengthByte, _ := reader.Peek(2) // 讀取前2個位元組,看看包頭
   lengthBuff := bytes.NewBuffer(lengthByte)
   var length int16
   // 讀取實際的包體長度
   err := binary.Read(lengthBuff, binary.LittleEndian, &length)
   if err != nil {
      return "", err
   }
   // Buffered返回緩衝中現有的可讀取的位元組數。
   if int16(reader.Buffered()) < length+2 {
      return "", err
   }

   // 讀取真正的訊息資料
   realData := make([]byte, int(2+length))
   _, err = reader.Read(realData)
   if err != nil {
      return "", err
   }
   return string(realData[2:]), nil
}

func process(conn net.Conn) {
   defer conn.Close()
   reader := bufio.NewReader(conn)

   for {
      msg, err := Decode(reader)
      if err == io.EOF {
         return
      }
      if err != nil {
         fmt.Println("Decode error : ", err)
         return
      }
      fmt.Println("received data :", msg)
   }
}

func main() {

   listen, err := net.Listen("tcp", "127.0.0.1:8888")
   if err != nil {
      fmt.Println("net.Listen error :", err)
      return
   }
   defer listen.Close()
   for {
      conn, err := listen.Accept()
      if err != nil {
         fmt.Println("listen.Accept error :", err)
         continue
      }
      go process(conn)
   }
}

client2.go

package main

import (
   "bytes"
   "encoding/binary"
   "fmt"
   "net"
)

// Encode 編碼訊息
func Encode(message string) ([]byte, error) {
   // 讀取訊息的長度,並且要 轉換成int16型別(佔2個位元組) ,我們約定好的 包頭2位元組
   var length = int16(len(message))
   var nb = new(bytes.Buffer)

   // 寫入訊息頭
   err := binary.Write(nb, binary.LittleEndian, length)
   if err != nil {
      return nil, err
   }

   // 寫入訊息體
   err = binary.Write(nb, binary.LittleEndian, []byte(message))
   if err != nil {
      return nil, err
   }
   return nb.Bytes(), nil
}

func main() {
   conn, err := net.Dial("tcp", "127.0.0.1:8888")
   if err != nil {
      fmt.Println("net.Dial error : ", err)
      return
   }
   defer conn.Close()
   for i := 0; i < 30; i++ {
      msg := `Hello world,hello xiaomotong!`

      data, err := Encode(msg)
      if err != nil {
         fmt.Println("Encode msg error : ", err)
         return
      }
      conn.Write(data)
   }
}

此處為了演示方便簡單,我們將封包放到了 客戶端程式碼中,拆包,放到了服務端程式碼中

效果演示

這下子,就不會存在黏包的問題了,因為tcp他知道自己每一次要讀多少長度的包,要是緩衝區資料不夠期望的長,那麼就等到資料夠了再一起讀出來,然後列印出來

看到這裡的朋友,對於golang的TCP程式設計還有點興趣了吧,那麼我們可以看看UDP程式設計了,相對TCP來說就簡單多了,不會有黏包的問題

GO基於UDP程式設計

同樣的,我們先來說說UDP協議

UDP協議(User Datagram Protocol)

是使用者資料包協議,一種無連線的傳輸層協議

不需要建立連線就能直接進行資料傳送和接收

屬於不可靠的、沒有時序的通訊,正是因為這樣的特點,所以UDP協議的實時性比較好,通常用於視訊直播相關領域,因為對於視訊傳輸,傳輸過程中丟點一些幀,對整體影響很小

UDP服務端

我們來擼一個UDP客戶端和服務端

server3.go

func main() {
    listen, err := net.ListenUDP("udp", &net.UDPAddr{
        IP:   net.IPv4(0, 0, 0, 0),
        Port: 8888,
    })
    if err != nil {
        fmt.Println("net.ListenUDP error : ", err)
        return
    }
    defer listen.Close()
    for {
        var data [1024]byte
        // 接收資料包文
        n, addr, err := listen.ReadFromUDP(data[:]) 
        if err != nil {
            fmt.Println("listen.ReadFromUDP error : ", 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("listen.WriteToUDP error:", err)
            continue
        }
    }
}

UDP客戶端

client3.go

func main() {
   socket, err := net.DialUDP("udp", nil, &net.UDPAddr{
      IP:   net.IPv4(0, 0, 0, 0),
      Port: 8888,
   })
   if err != nil {
      fmt.Println("net.DialUDP error : ", err)
      return
   }
   defer socket.Close()
   sendData := []byte("hello xiaomotong!!")
   // 傳送資料
   _, err = socket.Write(sendData)
   if err != nil {
      fmt.Println("socket.Write error : ", err)
      return
   }
   data := make([]byte, 2048)
   // 接收資料
   n, remoteAddr, err := socket.ReadFromUDP(data)
   if err != nil {
      fmt.Println("socket.ReadFromUDP error : ", err)
      return
   }
   fmt.Printf("data == %v  , addr == %v , count == %v\n", string(data[:n]), remoteAddr, n)
}

效果展示

服務端列印:
data == hello xiaomotong!!  , addr == 127.0.0.1:50487 , count == 18

客戶端列印:
data == hello xiaomotong!!  , addr == 127.0.0.1:8888 , count == 18

總結

  • 回顧網路的5層模型,SOCKET程式設計的服務端和客戶端的流程
  • GO基於TCP如何程式設計,如何解決TCP黏包問題
  • GO基於UDP如何程式設計

歡迎點贊,關注,收藏

朋友們,你的支援和鼓勵,是我堅持分享,提高質量的動力

好了,本次就到這裡,下一次 分享GO中如何設定HTTPS

技術是開放的,我們的心態,更應是開放的。擁抱變化,向陽而生,努力向前行。

我是小魔童哪吒,歡迎點贊關注收藏,下次見~

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章