首页 > 技术文章 > Golang和TCP&UDP的那些事儿

deehuang 2021-03-29 00:43 原文

TCP和UDP是传输层的两个重要协议,关于它们的理解网上资料比比皆是,这里不做过多叙述,本文主要写的是go语言中如何使用它们(就是学习一些内置的api调用)

Socket

socket也叫套接字,用以描述IP地址和端口,是一个通信链句柄,其可以理解为TCP/IP网络的API

socket屏蔽了OSI(Open System Interconnection,开放式系统互联)模型中应用层以下数据传输的各种复杂协议与实现,使用socket编程可以让程序员将注意力集中在应用层面上的开发,大大提升开发效率

TCP

我们知道,tcp有客户端和服务端之分。tcp服务端可以同时被多个客户端连接,在go语言中,对于每一个连接我们都可以创建一个goroutine去处理,以达到高效率、高性能、高并发的目的

Tcp服务端

服务端程序的处理流程主要分为三步:

  1. 监听端口
  2. 接收客户端请求并建立链接
  3. 创建goroutine处理链接

老样子,还是直接从代码去了解其使用

package main

import (
	"fmt"
	"net"
)

//tcp server
func main() {
	//1.启动tcp服务监听本地端口
	//1.1 使用内置的网络操作包net初始化网络服务
	listener, err := net.Listen("tcp", "127.0.0.1:18888") //参数1:协议 参数2:监听地址+端口 返回值:监听对象
	if err != nil {
		fmt.Println("初始化tcp服务失败", err)
		return
	}

	//2.等待客户端连接
	//2,1 使用net.Listener对象的Accept方法等待客户端连接, 注意:该方法是一个阻塞操作,直到接收客户端请求为止
	conn, err := listener.Accept() //返回值:连接对象
	if err != nil {
		fmt.Println("客户端连接失败", err)
		return
	}

	//3.与客户端唠嗑(通信)
	//3.1 使用net.Conn对象的Read方法读取客户端传输的数据
	var data [1024]byte
    //Read也是一个阻塞操作,会直到接收到客户端消息或客户端断开连接时,才结束阻塞
	n, err := conn.Read(data[:]) //参数:接收数据的byte切片 返回值:接收的数据大小
	if err != nil {
		fmt.Println("接收客户端数据失败", err)
		return
	}
	fmt.Println("接收的数据量:", n)
	fmt.Println(string(data[:n]))
}

接下来了解下客户端怎么写

Tcp客户端

客户端程序的处理流程主要分为两步:

  1. 与服务端建立连接
  2. 向客户端发送消息

少说多撸,写一段对应上面服务端的tcp客户端代码

package main

import (
	"fmt"
	"net"
)

// tcp client

func main() {
	//1.与tcp服务端建立连接
	//1.1 使用内置的网络操作包net连接tcp服务端
	conn, err := net.Dial("tcp", "127.0.0.1:18888") //参数1:协议 参数2:监听地址+端口 返回值:连接对象
	if err != nil {
		fmt.Println("连接服务端失败", err)
		return
	}

	//2.发送数据
	//2.1 使用net.Conn对象的Write方法向服务端发送的数据 注意:write不会阻塞
	n, err := conn.Write([]byte("hello my bro!")) //参数:字节切片
	if err != nil {
		fmt.Println("向服务端发送数据失败", err)
		return
	}
	fmt.Println("发送的数据量大小", n)
	conn.Close() //关闭连接
}

测试运行结果如下:

优化代码

通过上面的例子我们简单的了解了go语言中tcp server和tcp client的使用

但是在实际应用中,上面的代码还存在着一些问题:

  • 只能连接一个客户端
  • 只能接收一次客户端消息
  • 监听连接请求时候或接收消息时会阻塞当前程序执行

可以使用循环和goroutine解决上述问题:

代码优化后的server端:

package main

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

//tcp server
func main() {
	//1.启动tcp服务监听本地端口
	//1.1 使用内置的网络操作包net初始化网络服务
	listener, err := net.Listen("tcp", "127.0.0.1:18888") //参数1:协议 参数2:监听地址+端口 返回值:监听对象
	if err != nil {
		fmt.Println("初始化tcp服务失败", err)
		return
	}

	//2.等待客户端连接
	//2,1 使用net.Listener对象的Accept方法等待客户端连接, 注意:该方法是一个阻塞操作,直到接收客户端请求为止
	//使用死循环不断的去接收客户端的消息,直到客户端主动断开连接
	for {
		conn, err := listener.Accept() //返回值:连接对象
		if err != nil {
			fmt.Println("客户端连接失败", err)
			return //一般是客户端连接断开,此时结束循环
		}

		//使用协程单独处理每个连接发送的消息
		go processConn(conn)
	}

}

//开启goroutine异步处理连接发送的消息
func processConn(conn net.Conn) {
	reader := bufio.NewReader(os.Stdin)
	for { //死循环不断的接收客户端消息,直到连接端口
		//3.与客户端唠嗑(通信)
		//3.1 使用net.Conn对象的Read方法读取客户端传输的数据
		var data [1024]byte
		n, err := conn.Read(data[:]) //参数:接收数据的byte切片 返回值:接收的数据大小
		if err != nil {
			fmt.Println("接收客户端数据失败", err)
			return //一般是因为连接端口,此时结束循环
		}
		fmt.Printf("客户端:%s(接收客户端的数据量:%d)\n", string(data[:n]), n)

		//回复客户端的消息
		fmt.Println("请输入要回复的消息(按回车键发送消息)")
		msg, _ := reader.ReadString('\n')
		msg = strings.TrimSpace(msg)

		n, err = conn.Write([]byte(msg)) //参数:字节切片
		if err != nil {
			fmt.Println("向服务端发送数据失败", err)
			return
		}
		fmt.Printf("我:%s(向客户端发送的数据量大小%d)\n", msg, n)
	}

}

代码优化后的client端:

package main

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

// tcp client

func main() {
	//1.与tcp服务端建立连接
	//1.1 使用内置的网络操作包net连接tcp服务端
	conn, err := net.Dial("tcp", "127.0.0.1:18888") //参数1:协议 参数2:监听地址+端口 返回值:连接对象
	if err != nil {
		fmt.Println("连接服务端失败", err)
		return
	}

	//2.发送数据
	//2.1 使用net.Conn对象的Write方法向服务端发送的数据

	//程序进化1:让客户端允许从命令行接收消息去发送
	// if len(os.Args) > 0 { //从命令行接收参数,每个参数会放到[]string中,可遍历获取
	// 	msg = strings.Join(os.Args, ",") //拼接msg
	// }

	//程序进化2:也可以使用动态的从终端接收消息去控制程序流程
	reader := bufio.NewReader(os.Stdin)
	for {
		fmt.Println("请输入要发送的消息(按回车键发送消息,输出p结束程序:)")
		msg, _ := reader.ReadString('\n')
		msg = strings.TrimSpace(msg)
		if msg == "q" {
			break
		}

		n, err := conn.Write([]byte(msg)) //参数:字节切片
		if err != nil {
			fmt.Println("向服务端发送数据失败", err)
			return
		}
		fmt.Printf("我:%s(发送服务端的数据量大小%d)\n", msg, n)

		//接收服务端发送的数据
		var data [1024]byte
		n, err = conn.Read(data[:]) //参数:接收数据的byte切片 返回值:接收的数据大小
		if err != nil {
			fmt.Println("接收服务端数据失败", err)
			return //一般是因为连接端口,此时结束循环
		}
		fmt.Printf("服务端:%s(接收服务端的数据量:%d)\n", string(data[:n]), n)
	}

	conn.Close()
}

效果基本如下(可以启动多个接收多个客户端连接并独立去处理它们发送的消息):

Tcp黏包

什么是黏包?

tcp是基于流的协议,数据包像水流一样沿着管道(连接)流过去。在第三方视角看来,这样的数据不知道从哪里开始,到哪里结束

这就会可能导致某个数据包部分内容和上一个或者下一个数据包部分内容粘合到一起被接收者接收,这就是黏包现象

黏包可能发生在发送端,也可能发生在接收端,为了更好的区分它们发生的原因,首先我们要先了解下Nagle算法和tcp数据的缓存区

Nagle:是为了改善网络传输效率的算法,当我们向tcp连接发送一段数据时,tcp不会马上作出回应去发送这段数据,而是会等待一段时间看是否还有数据需要发送,若有则会一次性将这段时间内的数据一起发送出去

数据缓存区:tcp会把接收到的数据暂时缓存在一个缓冲区中并向应用层发出接收信号

  • 由于nagle算法引起的黏包现象就是发生在发送端的黏包
  • 当接收方没有及时的接收缓冲区中的数据时,就会造成多个数据包在缓冲区中的现象,这就可能会造成接收端黏包的情况
/*
	可以简单写个伪代码
		server端不断接收数据
		
		client端使用for循环多次的发送一段数据,由于for循环速度很快和Nagle算法的缘故,短时间内多条数据会一起发送到客户端去
	
	看到的现象就是,server端接收到的数据不是一段一段的,而是多条连在一块的
*/

代码我就偷懒不写了_

怎么解决

很简单,只需要双方做好协议即可,如:发送方每次发送数据包的前四个字节作为包头用来表示数据包的大小,客户端每次都先读取四个字节,获取到数据包的大小,再一次读取该数据包大小的数据就ok了

少说多撸

package proto

import (
	"bufio"
	"bytes"
	"encoding/binary"
)

//将数据包编码(即加上包头再转为二进制)
func Encode(mes string) ([]byte, error) {
	//获取发送数据的长度,并转换为四个字节的长度,即int32
	len := int(len(mes))
	//创建数据包
	dataPackage := new(bytes.Buffer) //使用字节缓冲区,一步步写入性能更高

	//先向缓冲区写入包头
	//大小端口诀:大端:尾端在高位,小端:尾端在低位
	//编码用小端写入,解码也要从小端读取,要保持一致
	err := binary.Write(dataPackage, binary.LittleEndian, len) //往存储空间小端写入数据
	if err != nil {
		return nil, err
	}

	//写入消息
	err = binary.Write(dataPackage, binary.LittleEndian, []byte(mes))
	if err != nil {
		return nil, err
	}

	return dataPackage.Bytes(), nil
}

//解码数据包
func Decode(reader *bufio.Reader) (string, error) {
	//读取数据包的长度(从包头获取)
	lenByte, _ := reader.Peek(4) //读取前四个字节的数据
	//转成Buffer对象,设置为从小端读取
	buff := bytes.NewBuffer(lenByte)

	len := 0                                            //读取的数据大小,初始化为0
	err := binary.Read(buff, binary.LittleEndian, &len) //从小端读取
	if err != nil {
		return "", err
	}

	//Buffered返回缓冲区中现有的可读取的字节数
	if reader.Buffered() < len+4 { //如果读取的包头的数据大小和读取到的不符合
		return "包头信息有误", err
	}

	//读取消息
	pkg := make([]byte, len+4)
	_, err = reader.Read(pkg)
	if err != nil {
		return "", err
	}
	return string(pkg[4:]), nil

}

拓展:大小端的概念:

在一个内存块里面写数据(计算机层面都是二进制数,并且数据写入最小单位是字节),存在把数据从右往左写入和从左往右写入的情况

  • 大端(BigEndian):数据从左往右写入(即一段数据的高位写在边)
  • 小端(LittleEndian):数据从右往左写入(即一段数据的高位写在右边)

注:如果要存储的数只是一个字节的数,那么大小端没有区别,原因是因为数据写入最小单位是字节

理解不了?上干货!(图片来至大佬博客,示例是一组16进制数)

UDP

UDP(用户数据报协议)是osi模型中一种无连接的传输层协议,它不需要像tcp一样收发数据需要建立连接,这也意味着数据的不可靠性,但是其有着很高的时效性,所以常用于视频直播等平台中

udp数据通讯的过程比较简单直接,下面就直接通过代码认识它吧

Udp服务端

因为udp是无连接的,所以其实服务端和服务端的功能是无差别的

区分哪一端主要是看那一方是被动接收数据的就是服务端,哪一方主动发送消息的就是客户端。也就是说udp客户端和服务端是一个相对的概念

package main

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

func main() {
	//1.初始化udp服务,监听端口
	//参数1:必须是udp  参数2:监听的地址和端口(是一个.net包封装好的upd结构体对象)
	listen, err := net.ListenUDP("udp", &net.UDPAddr{
		IP:   net.IPv4(127, 0, 0, 1), //监听ip地址
		Port: 8888,                   //端口
	})
	if err != nil {
		fmt.Println("初始化upd失败", err)

		return
	}
    
    defer listen.Close() //程序结束前,释放网络资源

	//不需要建立连接,可以直接通信,收发数据
	reader := bufio.NewReader(os.Stdin)
	for {
		var data [1024]byte
		//2. 接收客户端发送的消息
		//返回值中有客户端的地址。因为udp无连接,所以需要知道发送方的地址才能给其回消息
		n, addr, err := listen.ReadFromUDP(data[:])
		if err != nil {
			fmt.Println("读取数据失败", err)
			continue
		}
		fmt.Printf("客户端(%s):%s(接收客户端的数据量:%d)\n", addr.IP.String(), data[:n], n)

		//3. 向客户端发送消息
		fmt.Println("请输入要回复的消息(按回车键发送消息)")
		msg, _ := reader.ReadString('\n')
		msg = strings.TrimSpace(msg)
		n, err = listen.WriteToUDP([]byte(msg), addr) //注意要传写的地址
		if err != nil {
			fmt.Println("发送数据失败", err)
			continue
		}
		fmt.Printf("我:%s(发送给客户端的数据量:%d)\n", msg, n)

	}
}

Udp客户端

package main

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

func main() {
	//要通信的服务端地址
	serverAddr := net.UDPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: 8888,
	}

	//1.初始化udp服务,准备向服务端收发数据
	listen, err := net.ListenUDP("udp", &net.UDPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: 8889, //这是客户端的ip+端口,udp没有连接别搞错了
	})
	/*
		或使用下面方法主动拨号服务端创建也可以,更能表现客户端的特性(下面方式本质和上面指定监听地址和端口创建区别不大)
		和上面的区别在于因为指定了服务端,所以可以在发送数据的时候,可以直接通过listen.Write()发送数据,而无需指定服务端地址
	*/
	// listen, err = net.DialUDP("udp", nil, &serverAddr) //第二个参数是本端监听的地址+端口,如果为nil则会随即自动创建
	if err != nil {
		fmt.Println("初始化服务失败", err)
		return
	}
	defer listen.Close()
	//无需创建连接即可手法数据
	reader := bufio.NewReader(os.Stdin)
	for {
		//2. 给服务端发送数据
		fmt.Println("请输入要回复的消息(按回车键发送消息)")
		msg, _ := reader.ReadString('\n')
		n, err := listen.WriteToUDP([]byte(msg), &serverAddr)
		if err != nil {
			fmt.Println("发送数据失败", err)
			continue
		}
		fmt.Printf("我:%s(发送客户端的数据量:%d)\n", msg, n)

		//3. 接收服务端数据
		var data [1024]byte
		n, addr, err := listen.ReadFromUDP(data[:])
		if err != nil {
			fmt.Println("读取数据失败", err)
			continue
		}
		//事实上,在接收数据时,此upd角色就转变成服务端了,这是个相对的概念,不用太纠结
		fmt.Printf("服务端(%s):%s(接收服务端的数据量:%d)\n", addr.IP.String(), data[:n], n)
	}

}

推荐阅读