Golang socket websocket

理論知識可以參考
網絡信息怎么在網線中傳播的 (轉載自知乎)
Android 網絡(一) 概念 TCP/IP Socket Http Restful
腦殘式網絡編程入門(一):跟著動畫來學TCP三次握手和四次揮手
腦殘式網絡編程入門(二):我們在讀寫Socket時,究竟在讀寫什么?
TCP 粘包問題淺析及其解決方案,這個帖子里大家一頓噴粘包這個叫法
我工作五年的時候也不知道 “TCP 粘包”,繼續吐槽

一、API
1.服務端通過Listen加Accept
package main

import (
    "fmt"
    "net"
    "os"
    "time"
)

func main() {
    //通過 ResolveTCPAddr 獲取一個 TCPAddr
    //ResolveTCPAddr(net, addr string) (*TCPAddr, os.Error)
    
    //net參數是"tcp4"、"tcp6"、"tcp"中的任意一個,
    //分別表示 TCP(IPv4-only),TCP(IPv6-only)
    //或者 TCP(IPv4,IPv6 的任意一個)
    
    //addr 表示域名或者IP地址,
    //例如"www.google.com:80" 或者"127.0.0.1:22".
    service := ":7777"
    tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
    checkError(err)
    
    //ListenTCP(net string, laddr *TCPAddr) (l *TCPListener, err os.Error)
    listener, err := net.ListenTCP("tcp", tcpAddr)
    checkError(err)
    
    //func (l *TCPListener) Accept() (c Conn, err os.Error)
    for {
        conn, err := listener.Accept()
            if err != nil {
            continue
        }
        
        daytime := time.Now().String()
        // don't care about return value
        conn.Write([]byte(daytime)) 
        
        // we're finished with this client
        conn.Close() 
    }
}

func checkError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

上面的服務跑起來之后,它將會一直在那里等待,直到有新的客戶端請求到達。當有新的客戶端請求到達并同意接受 Accept 該請求的時候他會反饋當前的時間信息。值得注意的是,在代碼中 for 循環里,當有錯誤發生時,直接 continue而不是退出,是因為在服務器端跑代碼的時候,當有錯誤發生的情況下最好是由服務端記錄錯誤,然后當前連接的客戶端直接報錯而退出,從而不會影響到當前服務端運行的整個服務。

上面的代碼有個缺點,執行的時候是單任務的,不能同時接收多個請求,那么該如何改造以使它支持多并發呢?

...
for {
    conn, err := listener.Accept()
    if err != nil {
        continue
    }
    go handlerClient(conn)
}
...

func handleClient(conn net.Conn) {
    defer conn.Close()
    daytime := time.Now().String()
    // don't care about return value
    conn.Write([]byte(daytime)) 
    
    // we're finished with this client
}
...
2.客戶端直接調用 Dial
package main
    import (
        "fmt"
        "io/ioutil"
        "net"
        "os"
    )
    
    func main() {
        if len(os.Args) != 2 {
            fmt.Fprintf(os.Stderr, "Usage: %s host:port ", os.Args[0])
            os.Exit(1)
        }
    
        service := os.Args[1]
        tcpAddr, err := net.ResolveTCPAddr("tcp4", service)
        checkError(err)
        
        conn, err := net.DialTCP("tcp", nil, tcpAddr)
        checkError(err)
        
        _, err = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))
        checkError(err)
        
        result, err := ioutil.ReadAll(conn)
        checkError(err)
        
        fmt.Println(string(result))
        os.Exit(0)
    }
    
    func checkError(err error) {
        if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}

首先程序將用戶的輸入作為參數 service 傳入net.ResolveTCPAddr 獲取一個 tcpAddr,然后把 tcpAddr 傳入 DialTCP 后創建了一個 TCP連接 conn ,通過 conn 來發送請求信息,最后通過 ioutil.ReadAll 從 conn 中讀取全部的文本,也就是服務端響應反饋的信息。

二、實現一個可以接受不同命令的服務端

參考使用 Go 進行 Socket 編程
我們實現一個服務端, 它可以接受下面這些命令:

  • ping 探活的命令, 服務端會返回 “pong”
  • echo 服務端會返回收到的字符串
  • quit 服務端收到這個命令后就會關閉連接

具體的服務端代碼如下所示:

package main

import (
    "fmt"
    "net"
    "strings"
)

func connHandler(c net.Conn) {
    if c == nil {
        return
    }

    buf := make([]byte, 4096)

    for {
        cnt, err := c.Read(buf)
        if err != nil || cnt == 0 {
            c.Close()
            break
        }

        inStr := strings.TrimSpace(string(buf[0:cnt]))

        inputs := strings.Split(inStr, " ")

        switch inputs[0] {
        case "ping":
            c.Write([]byte("pong\n"))
        case "echo":
            echoStr := strings.Join(inputs[1:], " ") + "\n"
            c.Write([]byte(echoStr))
        case "quit":
            c.Close()
            break
        default:
            fmt.Printf("Unsupported command: %s\n", inputs[0])
        }
    }

    fmt.Printf("Connection from %v closed. \n", c.RemoteAddr())
}

func main() {
    server, err := net.Listen("tcp", ":1208")
    if err != nil {
        fmt.Printf("Fail to start server, %s\n", err)
    }

    fmt.Println("Server Started ...")

    for {
        conn, err := server.Accept()
        if err != nil {
            fmt.Printf("Fail to connect, %s\n", err)
            break
        }

        go connHandler(conn)
    }
}

客戶端的實現

package main

import (
    "bufio"
    "fmt"
    "net"
    "os"
    "strings"
)

func connHandler(c net.Conn) {
    defer c.Close()

    reader := bufio.NewReader(os.Stdin)
    buf := make([]byte, 1024)

    for {
        input, _ := reader.ReadString('\n')
        input = strings.TrimSpace(input)

        if input == "quit" {
            return
        }

        c.Write([]byte(input))

        cnt, err := c.Read(buf)
        if err != nil {
            fmt.Printf("Fail to read data, %s\n", err)
            continue
        }

        fmt.Print(string(buf[0:cnt]))
    }
}

func main() {
    conn, err := net.Dial("tcp", "localhost:1208")
    if err != nil {
        fmt.Printf("Fail to connect, %s\n", err)
        return
    }

    connHandler(conn)
}
三、解決golang開發socket服務時粘包半包bug

基礎知識可以參考tcp是流的一些思考--拆包和粘包
tcp中有一個negal算法,用途是這樣的:通信兩端有很多小的數據包要發送,雖然傳送的數據很少,但是流程一點沒少,也需要tcp的各種確認,校驗。這樣小的數據包如果很多,會造成網絡資源很大的浪費,negal算法做了這樣一件事,當來了一個很小的數據包,我不急于發送這個包,而是等來了更多的包,將這些小包組合成大包之后一并發送,不就提高了網絡傳輸的效率的嘛。這個想法收到了很好的效果,但是我們想一下,如果是分屬于兩個不同頁面的包,被合并在了一起,那客戶那邊如何區分它們呢?
這就是粘包問題。從粘包問題我們更可以看出為什么tcp被稱為流協議,因為它就跟水流一樣,是沒有邊界的,沒有消息的邊界保護機制,所以tcp只有流的概念,沒有包的概念。

解決tcp粘包的方法:
客戶端會定義一個標示,比如數據的前4位是數據的長度,后面才是數據。那么客戶端只需發送 ( 數據長度+數據 ) 的格式數據就可以了,接收方根據包頭信息里的數據長度讀取buffer.
客戶端:

//客戶端發送封包
package main

import (
    "fmt"
    "math/rand"
    "net"
    "os"
    "strconv"
    "strings"
    "time"
)

func main() {

    server := "127.0.0.1:5000"
    tcpAddr, err := net.ResolveTCPAddr("tcp4", server)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }

    conn, err := net.DialTCP("tcp", nil, tcpAddr)
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }

    defer conn.Close()

    for i := 0; i < 50; i++ {
        //msg := strconv.Itoa(i)
        msg := RandString(i)
        msgLen := fmt.Sprintf("%03s", strconv.Itoa(len(msg)))
        //fmt.Println(msg, msgLen)
        words := "aaaa" + msgLen + msg
        //words := append([]byte("aaaa"), []byte(msgLen), []byte(msg))
        fmt.Println(len(words), words)
        conn.Write([]byte(words))
    }
}

/**
*生成隨機字符
**/
func RandString(length int) string {
    rand.Seed(time.Now().UnixNano())
    rs := make([]string, length)
    for start := 0; start < length; start++ {
        t := rand.Intn(3)
        if t == 0 {
            rs = append(rs, strconv.Itoa(rand.Intn(10)))
        } else if t == 1 {
            rs = append(rs, string(rand.Intn(26)+65))
        } else {
            rs = append(rs, string(rand.Intn(26)+97))
        }
    }
    return strings.Join(rs, "")
}

服務端實例代碼:

package main

import (
    "fmt"
    "io"
    "net"
    "os"
    "strconv"
)

func main() {
    netListen, err := net.Listen("tcp", ":5000")
    CheckError(err)

    defer netListen.Close()

    for {
        conn, err := netListen.Accept()
        if err != nil {
            continue
        }

        go handleConnection(conn)
    }
}

func handleConnection(conn net.Conn) {
    allbuf := make([]byte, 0)
    buffer := make([]byte, 1024)
    for {
        readLen, err := conn.Read(buffer)
        //fmt.Println("readLen: ", readLen, len(allbuf))
        if err == io.EOF {
            break
        }
        if err != nil {
            fmt.Println("read error")
            return
        }

        if len(allbuf) != 0 {
            allbuf = append(allbuf, buffer...)
        } else {
            allbuf = buffer[:]
        }
        var readP int = 0
        for {
            //fmt.Println("allbuf content:", string(allbuf))

            //buffer長度小于7
            if readLen-readP < 7 {
                allbuf = buffer[readP:]
                break
            }

            msgLen, _ := strconv.Atoi(string(allbuf[readP+4 : readP+7]))
            logLen := 7 + msgLen
            //fmt.Println(readP, readP+logLen)
            //buffer剩余長度>將處理的數據長度
            if len(allbuf[readP:]) >= logLen {
                //fmt.Println(string(allbuf[4:7]))
                fmt.Println(string(allbuf[readP : readP+logLen]))
                readP += logLen
                //fmt.Println(readP, readLen)
                if readP == readLen {
                    allbuf = nil
                    break
                }
            } else {
                allbuf = buffer[readP:]
                break
            }
        }
    }
}

func CheckError(err error) {
    if err != nil {
        fmt.Fprintf(os.Stderr, "Fatal error: %s", err.Error())
        os.Exit(1)
    }
}
四、io包的ReadFull

對于第三部分的解決golang開發socket服務時粘包半包bug,有作者認為太復雜了,參見golang tcp拆包的正確姿勢,他提出可以用ReadFull來簡化。

關于io包基礎知識,參考Golang io reader writer
關于ReadFull,可以參考達達的博客系列:
Go語言小貼士1 - io包
Go語言小貼士2 - 協議解析
Go語言小貼士3 - bufio包

原文不再轉述,現在引用一下重點:

io.Reader的定義如下:

type Reader interface {
        Read(p []byte) (n int, err error)
}

其中文檔的說明非常重要,文檔中詳細描述了Read方法的各種返回可能性。

文檔描述中有一個要點,就是n可能小于等于len(p),也就是說Go在讀IO的時候,是不會保證一次讀取預期的所有數據的。如果我們要確保一次讀取我們所需的所有數據,就需要在一個循環里調用Read,累加每次返回的n并小心設置下次Readp的偏移量,直到n的累加值達到我們的預期。

因為上述需求實在太常見了,所以Go在io包中提供了一個ReadFull函數來做到一次讀取要求的所有數據,通過閱讀ReadFull函數的代碼,也可以反過來幫助大家理解io.Reader是怎么運作的。

//io.go源碼
func ReadFull(r Reader, buf []byte) (n int, err error) {
    return ReadAtLeast(r, buf, len(buf))
}

func ReadAtLeast(r Reader, buf []byte, min int) (n int, err error) {
    if len(buf) < min {
        return 0, ErrShortBuffer
    }
    for n < min && err == nil {
        var nn int
        nn, err = r.Read(buf[n:])
        n += nn
    }
    if n >= min {
        err = nil
    } else if n > 0 && err == EOF {
        err = ErrUnexpectedEOF
    }
    return
}

在很多應用場景中,消息包的長度是不固定的,就像上面的字符串字段一樣。我們一樣可以用開頭固定的幾個字節來存放消息長度,在解析通訊協議的時候就可以從字節流中截出一個個的消息包了,這樣的操作通常叫做協議分包或者粘包處理。
貼個從Socket讀取消息包的偽代碼(沒編譯):

func ReadPacket(conn net.Conn) ([]byte, error) {
        var head [2]byte

        if _, err := io.ReadFull(conn, head[:]); err != nil {
                return err
        }

        size := binary.BigEndian.Uint16(head)
        packet := make([]byte, size)

        if _, err := io.ReadFull(conn, packet); err != nil {
                return err
        }

        return packet
}

上面的代碼就用到了前一個小貼士中說到的io.ReadFull來確保一次讀取完整數據。

要注意,這段代碼不是線程安全的,如果有兩個線程同時對一個net.Conn進行ReadPacket操作,很可能會發生嚴重錯誤,具體邏輯請自行分析。

從上面結構體序列化和反序列化的代碼中,大家不難看出,實現一個二進制協議是挺繁瑣和容易出BUG的,只要稍微有一個數值計算錯就解析出錯了。所以在工程實踐中,不推薦大家手寫二進制協議的解析代碼,項目中通常會用自動化的工具來輔助生成代碼。

Leaf 游戲服務器框架簡介的tcp_msg.go中,Read方法也使用了ReadFull這種方式來處理。

五、WebSocket

參考封裝golang websocket
websocket是個二進制協議,需要先通過Http協議進行握手,從而協商完成從Http協議向websocket協議的轉換。一旦握手結束,當前的TCP連接后續將采用二進制websocket協議進行雙向雙工交互,自此與Http協議無關。

可以通過這篇知乎了解一下websocket協議的基本原理:《WebSocket 是什么原理?為什么可以實現持久連接?》

1.粘包

我們開發過TCP服務的都知道,需要通過協議decode從TCP字節流中解析出一個一個請求,那么websocket又怎么樣呢?

websocket以message為單位進行通訊,本身就是一個在TCP層上的一個分包協議,其實并不需要我們再進行粘包處理。但是因為單個message可能很大很大(比如一個視頻文件),那么websocket顯然不適合把一個視頻作為一個message傳輸(中途斷了前功盡棄),所以websocket協議其實是支持1個message分多個frame幀傳輸的。

我們的瀏覽器提供的編程API都是message粒度的,把frame拆幀的細節對開發者隱蔽了,而服務端websocket框架一般也做了同樣的隱藏,會自動幫我們收集所有的frame后拼成messasge再回調,所以結論就是:

websocket以message為單位通訊,不需要開發者自己處理粘包問題。

更多參考Websocket需要像TCP Socket那樣進行邏輯數據包的分包與合包嗎?

2.golang實現

golang官方標準庫里有一個websocket的包,但是它提供的就是frame粒度的API,壓根不能用。

不過官方其實已經認可了一個準標準庫實現,它實現了message粒度的API,讓開發者不需要關心websocket協議細節,開發起來非常方便,其文檔地址:https://godoc.org/github.com/gorilla/websocket

開發websocket服務時,首先要基于http庫對外暴露接口,然后由websocket庫接管TCP連接進行協議升級,然后進行websocket協議的數據交換,所以開發時總是要用到http庫和websocket庫。

上述websocket文檔中對開發websocket服務有明確的注意事項要求,主要是指:

  • 讀和寫API不是并發安全的,需要啟動單個goroutine串行處理。
  • 關閉API是線程安全的,一旦調用則阻塞的讀和寫API會出錯返回,從而終止處理。
六、心跳實現

Golang 心跳的實現
在多客戶端同時訪問服務器的工作模式下,首先要保證服務器的運行正常。因此,Server和Client建立通訊后,確保連接的及時斷開就非常重要。否則,多個客戶端長時間占用著連接不關閉,是非常可怕的服務器資源浪費。會使得服務器可服務的客戶端數量大幅度減少。因此,針對短鏈接和長連接,根據業務的需求,配套不同的處理機制。

  • 短連接:一般建立完連接,就立刻傳輸數據。傳輸完數據,連接就關閉。服務端根據需要,設定連接的時長。超過時間長度,就算客戶端超時。立刻關閉連接。
  • 長連接:建立連接后,傳輸數據,然后要保持連接,然后再次傳輸數據。直到連接關閉。

socket讀寫可以通過 SetDeadline、SetReadDeadline、SetWriteDeadline設置阻塞的時間。

func (*IPConn) SetDeadline  
func (c *IPConn) SetDeadline(t time.Time) error  

func (*IPConn) SetReadDeadline  
func (c *IPConn) SetReadDeadline(t time.Time) error  

func (*IPConn) SetWriteDeadline 
func (c *IPConn) SetWriteDeadline(t time.Time) error

如果做短連接,直接在Server端的連接上設置SetReadDeadline。當你設置的時限到達,無論客戶端是否還在繼續傳遞消息,服務端都不會再接收。并且已經關閉連接。

func main() {
    server := ":7373"
    netListen, err := net.Listen("tcp", server)
    if err != nil{
        Log("connect error: ", err)
        os.Exit(1)
    }
    Log("Waiting for Client ...")
    for{
        conn, err := netListen.Accept()
        if err != nil{
            Log(conn.RemoteAddr().String(), "Fatal error: ", err)
            continue
        }

        //設置短連接(10秒)
        conn.SetReadDeadline(time.Now().Add(time.Duration(10)*time.Second))

        Log(conn.RemoteAddr().String(), "connect success!")
        ...
    }
}

這就可以了。在這段代碼中,每當10秒中的時限一道,連接就終止了。

根據業務需要,客戶端可能需要長時間保持連接。但是服務端不能無限制的保持。這就需要一個機制,如果超過某個時間長度,服務端沒有獲得客戶端的數據,就判定客戶端已經不需要連接了(比如客戶端掛掉了)。做到這個,需要一個心跳機制。在限定的時間內,客戶端給服務端發送一個指定的消息,以便服務端知道客戶端還活著。

func sender(conn *net.TCPConn) {
    for i := 0; i < 10; i++{
        words := strconv.Itoa(i)+" Hello I'm MyHeartbeat Client."
        msg, err := conn.Write([]byte(words))
        if err != nil {
            Log(conn.RemoteAddr().String(), "Fatal error: ", err)
            os.Exit(1)
        }
        Log("服務端接收了", msg)
        time.Sleep(2 * time.Second)
    }
    for i := 0; i < 2 ; i++ {
        time.Sleep(12 * time.Second)
    }
    for i := 0; i < 10; i++{
        words := strconv.Itoa(i)+" Hi I'm MyHeartbeat Client."
        msg, err := conn.Write([]byte(words))
        if err != nil {
            Log(conn.RemoteAddr().String(), "Fatal error: ", err)
            os.Exit(1)
        }
        Log("服務端接收了", msg)
        time.Sleep(2 * time.Second)
    }

}

這段客戶端代碼,實現了兩個相同的信息發送頻率給服務端。兩個頻率中間,我們讓運行休息了12秒。然后,我們在服務端的對應機制是這樣的。

func HeartBeating(conn net.Conn, bytes chan byte, timeout int) {
    select {
    case fk := <- bytes:
        Log(conn.RemoteAddr().String(), "心跳:第", string(fk), "times")
        conn.SetDeadline(time.Now().Add(time.Duration(timeout) * time.Second))
        break

        case <- time.After(5 * time.Second):
            Log("conn dead now")
            conn.Close()
    }
}

每次接收到心跳數據就 SetDeadline 延長一個時間段 timeout。如果沒有接到心跳數據,5秒后連接關閉。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 網絡編程 一.楔子 你現在已經學會了寫python代碼,假如你寫了兩個python文件a.py和b.py,分別去運...
    go以恒閱讀 2,076評論 0 6
  • 說明 本文 翻譯自 realpython 網站上的文章教程 Socket Programming in Pytho...
    keelii閱讀 2,159評論 0 16
  • 目錄一、socket是什么,socket和HTTP的區別二、如何建立一個socket連接三、使用CocoaAsyn...
    意一ineyee閱讀 1,661評論 0 13
  • 計算機網絡概述 網絡編程的實質就是兩個(或多個)設備(例如計算機)之間的數據傳輸。 按照計算機網絡的定義,通過一定...
    蛋炒飯_By閱讀 1,243評論 0 10
  • ES6(ECMAScript2015)的出現,無疑給前端開發人員帶來了新的驚喜,它包含了一些很棒的新特性,可以更加...
    c蓋世閱讀 169評論 0 1