[轉]Go網絡編程 · 一條TCP連接講透九大知識點

 

 

轉,原文: https://cooolin.com/scinet/2020/07/10/golang-tcp-client.html

--------------------------

 

項目做了半年,現在要開發iOS版本。由於iOS的Network Extension對內存有15M限制,現成的實現方案都太耗內存,需要自己從頭開發一個精簡版。所以最近兩週在學Go語言。

話不多說,正式開始,分爲幾步逐步來完善一個TCP客戶端:

  1. 用nc做簡單服務器
  2. 用Dial建立TCP客戶端
  3. 用defer在函數退出時執行關閉操作
  4. 用Read接收服務端消息
  5. 用io.EOF來識別正常讀取結束
  6. 用for持續讀取服務端消息
  7. 用go啓動協程實現交互
  8. 用chan的協程間通信實現控制流程
  9. 用select的多通道監聽實現簡單超時
  10. 用context實現標準超時

0 - 用nc作簡單服務器

由於後面要做TCP客戶端,需要一個方便可調試的服務端,也可以自己用Go寫一個,但不是本篇重點,因此選用有網絡工具瑞士軍刀之稱的Netcat

啓動服務器,監聽1234端口

nc -l 1234

客戶端測試連接

nc -v 127.0.0.1 1234

嘗試在兩端隨意輸入文字,會傳輸到另外一側,按Ctrl+C中斷連接

1 - 用Dial建立TCP客戶端

編輯tcpclient.go文件:

package main

import (
    "net"
)

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:1234")  // 以TCP協議連通127.0.0.1的1234端口
    if err != nil {
        conn.Close()
        panic(err)
    }
    c := []byte("hello\n")  // 將字符串轉換爲byte數組(slice)
    conn.Write(c)  // 將byte寫入連接/發送至服務端
    conn.Close()
}

可以參考 net.Dial ,返回的conn實現了 io.Reader 和 io.Writer 接口

啓動nc服務器

nc -l 1234

運行並向nc服務器發送hello

go run tcpclient.go

查看nc服務器那邊,會顯示接收到hello

2 - 用defer在函數退出時執行關閉操作

以上代碼邏輯沒問題,但有個不優雅的地方,就是我們在if err != nil的分支和最後兩次調用了conn.Close(),如果一個正常函數,有七八處判斷error的地方,難道我們也要寫這麼多次?

答案是deferdefer是用於安排一段邏輯在離開函數時執行的,修改後的代碼是:

package main

import (
    "net"
)

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:1234")
    if err != nil {
        panic(err)
    }
    defer conn.Close()  // 這段邏輯一定會在以任何方式離開函數時執行
    c := []byte("hello\n")
    conn.Write(c)
}

3 - 用Read接收服務端消息

上面一步我們只實現了發送消息,那麼接收服務端消息要怎麼實現呢?

修改一下上一步的代碼:

package main

import (
    "net"
    "fmt"
)

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:1234")
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    c := []byte("hello\n")
    conn.Write(c)

    buf := make([]byte, 1024)  // 創建長度爲1k的緩存區
    n, err := conn.Read(buf)  // 將接收到的服務端內容放入緩存區,並返回此次讀取的長度
    if err != nil {
        panic(err)
    }
    fmt.Printf("received: %v", string(buf[:n]))  // 打印出來
}

make 是Go的內置函數; 上節提到conn實現了 io.Reader 接口,因此它有Read方法; 與Read相關的函數還有很多,這裏僅介紹這個是因爲他是其他一切高級Read函數的基礎。

啓動後在服務端輸入how are you,會在客戶端收到received: how are you

注意 conn.Read() 函數會阻塞住程序,直到它真正讀取到數據,這很關鍵。

4 - 用io.EOF來識別正常讀取結束

再次啓動服務端和客戶端,然後嘗試在服務端按Ctrl+C中斷,觀察客戶端,拋出了異常。但在實際生產環境中,服務端因爲各種原因關閉連接是很正常的,那我們要如何來判斷呢?答案是io.EOF

package main

import (
    "net"
    "fmt"
    "io"  // 注意引入io包
)

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:1234")
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    c := []byte("hello\n")
    conn.Write(c)

    buf := make([]byte, 1024)
    n, err := conn.Read(buf)
    if err != nil {
        if err == io.EOF {  // 若異常是io.EOF,則正常退出函數不做panic
            return
        }
        panic(err)
    }
    fmt.Printf("received: %v", string(buf[:n]))
}

以後所有讀取的地方,都需要注意這點。

5 - 用for持續讀取服務端消息

上面僅能讀取一次服務端消息,有沒有辦法讓它能持續接收呢?答案是用for循環

package main

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

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:1234")
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    c := []byte("hello\n")
    conn.Write(c)

    buf := make([]byte, 1024)

    for {  // 無參for指令代表無限循環
        n, err := conn.Read(buf)
        if err != nil {
            if err == io.EOF {
                return
            }
            panic(err)
        }
        fmt.Printf("received: %v", string(buf[:n]))
    }
}

再次運行,在服務端輸入nice to meet youwhats up,觀察客戶端的表現。按Ctrl+C結束客戶端程序。

6 - 用go啓動協程實現交互

以上代碼已經完成了客戶端持續接收服務端消息的能力,但客戶端卻不能對自己有任何操作,我們需要實現客戶端同時可以輸入。

先看一段錯誤代碼:

package main

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

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:1234")
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    c := []byte("hello\n")
    conn.Write(c)

    buf := make([]byte, 1024)

    for {
        n, err := conn.Read(buf)
        if err != nil {
            if err == io.EOF {
                return
            }
            panic(err)
        }
        fmt.Printf("received: %v", string(buf[:n]))

        // 客戶端可以輸入消息併發送到服務端
        var inp string
        fmt.Scanln(&inp)
        conn.Write([]byte(inp + "\n"))
    }
}

啓動之後我們會發現,僅有在服務端發送消息給客戶端後,客戶端才能夠開始輸入消息,這是因爲我們之前說的conn.Read()會阻塞整個代碼。那我們該怎麼辦呢?答案是協程(goroutine),看下面修改的代碼:

package main

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

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:1234")
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    c := []byte("hello\n")
    conn.Write(c)

    // 將讀取部分放入到子協程中,不阻塞主協程運行
    go func() {
        buf := make([]byte, 1024)
        for {
            n, err := conn.Read(buf)
            if err != nil {
                if err == io.EOF {
                    return
                }
                panic(err)
            }
            fmt.Printf("received: %v", string(buf[:n]))
        }
    }()

    // 客戶端可以輸入消息併發送到服務端
    for {
        var inp string
        fmt.Scanln(&inp)
        conn.Write([]byte(inp + "\n"))
    }
}

go指令啓動子協程,其中任何操作都不會阻塞當前協程,因此主協程會直接執行到輸入指令處。

7 - 用chan的協程間通信實現控制流程

上面的程序已經運轉良好了,如果我們現在有一個需求,是當接收到服務端發來的bye消息時,客戶端退出,該如何實現?可以先思考一下再看代碼

package main

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

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:1234")
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    c := []byte("hello\n")
    conn.Write(c)

    quit := make(chan string, 1)  // 1. 創建長度爲1的通道(chan)

    // 讀取協程
    go func() {
        buf := make([]byte, 1024)
        for {
            n, err := conn.Read(buf)
            if err != nil {
                if err == io.EOF {
                    return
                }
                panic(err)
            }
            r := string(buf[:n])
            fmt.Printf("received: %v", r)

            if r == "bye\n" {  // 若接收到服務端發送過來的bye
                quit <-"server quit"  // 3. 向通道內寫入內容
                return
            }
        }
    }()

    // 用戶輸入協程
    go func() {  // 將用戶輸入也變爲子協程
        for {
            var inp string
            fmt.Scanln(&inp)
            conn.Write([]byte(inp + "\n"))
        }
    }()

    r := <-quit  // 2. 嘗試從通道中讀取內容,若通道爲空,則阻塞在此
    fmt.Printf("command: %v", r)
}

以上代碼解讀:

  1. 將阻塞操作用戶輸入也變爲與網絡讀取一樣的子協程
  2. 創建通道quit
  3. 在主協程最後,嘗試從quit通道中取出內容,若通道爲空,則阻塞在此
  4. 在讀取協程中,判斷若服務端發送過來bye,則向quit通道中寫入一個值(這個值可以是任意值)
  5. 一旦quit中被寫入一個值,r := <-quit就會成功取出,不再阻塞,退出程序

所以,關鍵點是通道的取出是阻塞的

練習題:嘗試將以上代碼改爲客戶端輸入bye也可以退出

8 - 用select的多通道監聽實現簡單超時

如果我們希望添加超時怎麼做?

思路其實很簡單,要超時退出,就是要在剛剛以上的bye命令通知機制上,再加上時間通知。

package main

import (
    "net"
    "fmt"
    "io"
    "time"  // 引入time包
)

func main() {
    conn, err := net.Dial("tcp", "127.0.0.1:1234")
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    c := []byte("hello\n")
    conn.Write(c)

    quit := make(chan string, 1)

    // 讀取協程
    go func() {
        buf := make([]byte, 1024)
        for {
            n, err := conn.Read(buf)
            if err != nil {
                if err == io.EOF {
                    return
                }
                panic(err)
            }
            r := string(buf[:n])
            fmt.Printf("received: %v", r)

            if r == "bye\n" {
                quit <-"server quit"
                return
            }
        }
    }()

    // 用戶輸入協程
    go func() {
        for {
            var inp string
            fmt.Scanln(&inp)
            conn.Write([]byte(inp + "\n"))
        }
    }()

    // 將簡單的讀取quit通道,改爲select多路通道監聽
    select {
    case r := <-quit:
        fmt.Printf("command: %v", r)
    case <-time.After(5 * time.Second):  // 新增一個通道條件是5s之後通道中有值
        fmt.Printf("timeout")
    }
}

time.After 返回一個通道,在指定時間到達時,將向通道內寫入當前時間。而 select 關鍵字則用於多通道監聽。

運行後,任意互動,5s後程序就會退出。

9 - 用context實現標準超時

我們再看另一種更標準化的超時:

package main

import (
    "net"
    "fmt"
    "io"
    "time"
    "context"
)

func main() {
    // 創建context用於協程間傳遞
    ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
    defer cancel()  // 函數退出時需關閉context

    conn, err := net.Dial("tcp", "127.0.0.1:1234")
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    c := []byte("hello\n")
    conn.Write(c)

    quit := make(chan string, 1)

    // 讀取協程
    go func() {
        buf := make([]byte, 1024)
        for {
            n, err := conn.Read(buf)
            if err != nil {
                if err == io.EOF {
                    return
                }
                panic(err)
            }
            r := string(buf[:n])
            fmt.Printf("received: %v", r)

            if r == "bye\n" {
                quit <-"server quit"
                return
            }
        }
    }()

    // 用戶輸入協程
    go func() {
        for {
            var inp string
            fmt.Scanln(&inp)
            conn.Write([]byte(inp + "\n"))
        }
    }()

    select {
    case r := <-quit:
        fmt.Printf("command: %v", r)
    case <-ctx.Done():  // 改爲監聽ctx.Done()通道
        fmt.Printf("timeout")
    }
}

效果和剛剛一樣,那爲什麼要用這種而不是time.After()?因爲context更加強大,除此之外還可以做很多事情,看下面我們優化的代碼:

package main

import (
    "net"
    "fmt"
    "io"
    "time"
    "context"
)

func main() {
    // 創建context用於協程間傳遞
    ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
    defer cancel()  // 函數退出時需關閉context

    var dialer net.Dialer  // 創建dialer
    conn, err := dialer.DialContext(ctx, "tcp", "127.0.0.1:1234")  // 在連接時就將context傳入,可以確保連接時長也受context限制
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    c := []byte("hello\n")
    conn.Write(c)

    // 讀取協程
    go func() {
        buf := make([]byte, 1024)
        for {
            n, err := conn.Read(buf)
            if err != nil {
                if err == io.EOF || ctx.Err() != nil {  // 由context進行的主動退出,不要panic
                    return
                }
                panic(err)
            }
            r := string(buf[:n])
            fmt.Printf("received: %v", r)

            if r == "bye\n" {
                cancel()  // 直接調用cancel函數完成退出消息傳遞
                return
            }
        }
    }()

    // 用戶輸入協程
    go func() {
        for {
            var inp string
            fmt.Scanln(&inp)
            conn.Write([]byte(inp + "\n"))
        }
    }()

    select {
    case <-ctx.Done():  // 僅監聽ctx.Done()通道
    }

    // 根據context的異常來判斷退出原因
    err = ctx.Err()
    if err == context.Canceled {
        fmt.Printf("user canceled")
    } else if err == context.DeadlineExceeded {
        
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章