GO的网络编程分享

1,766 阅读13分钟

[TOC]

这是我参与更文挑战的第 7 天,活动详情查看: 更文挑战

GO的网络编程分享

回顾一下我们上次分享的网络协议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

技术是开放的,我们的心态,更应是开放的。拥抱变化,向阳而生,努力向前行。

我是小魔童哪吒,欢迎点赞关注收藏,下次见~