TCP的粘包、拆包、解决方案以及Go语言实现

什么是粘包,拆包?

  • TCP的粘包和拆包问题往往出现在基于TCP协议的通讯中,比如RPC框架
  • 在使用TCP进行数据传输时,由于TCP是基于字节流的协议,而不是基于消息的协议,可能会出现粘包(多个消息粘在一起)和拆包(一个消息被拆分成多个部分)的问题。这些问题可能会导致数据解析错误或数据不完整。

为什么UDP没有粘包?

  • 由于UDP没有像TCP那样的流控制和拥塞控制机制,它不会对数据进行缓冲或重组。因此,在UDP中,每个数据报都是独立传输的(接收端一次只能接受一条独立的消息),不存在多个消息粘在一起的问题,也就没有粘包的概念。
  • 由于UDP是不可靠的传输协议,它无法保证数据的可靠传输和顺序传输。数据包可能会丢失、重复或乱序到达。在使用UDP时,应该自行处理这些问题,比如使用应答机制、超时重传等手段来保证数据的可靠性和正确性。

粘包拆包发生场景

因为TCP是面向流,没有边界,而操作系统在发送TCP数据时,会通过缓冲区来进行优化,例如缓冲区为1024个字节大小。

  • 如果一次请求发送的数据量比较小,没达到缓冲区大小,TCP则会将多个请求合并为同一个请求进行发送,这就形成了粘包问题。
  • 如果一次请求发送的数据量比较大,超过了缓冲区大小,TCP就会将其拆分为多次发送,这就是拆包。

关于粘包和拆包可以参考下图的几种情况:

  • 理想状况:两个数据包逐一分开发送
  • 粘包:两个包一同发送,
  • 拆包:Server接收到不完整的或多出一部分的数据包

TCP的粘包、拆包、解决方案以及Go语言实现_第1张图片

常见的解决方案

  • 固定长度:发送端将每个消息固定为相同的长度,接收端按照固定长度进行拆包。这样可以确保每个消息的长度是一致的,但是对于不同长度的消息可能会浪费一些空间。
  • 分隔符:发送端在每个消息的末尾添加一个特殊的分隔符(比如换行符或特殊字符),接收端根据分隔符进行拆包。这种方法适用于消息中不会出现分隔符的情况。
  • 消息长度前缀:发送端在每个消息前面添加一个固定长度的消息长度字段,接收端先读取消息长度字段,然后根据长度读取相应长度的数据。这种方法可以准确地拆分消息,但需要保证消息长度字段的一致性。

代码实现

固定长度

发送端将每个包都封装成固定的长度,比如20字节大小。如果不足20字节可通过补0或空等进行填充到指定长度;

服务端

package main

import (
	"fmt"
	"log"
	"net"
)

func main() {
	// 监听指定的TCP端口
	listener, err := net.Listen("tcp", "localhost:8080")
	if err != nil {
		log.Fatal(err)
	}
	defer listener.Close()

	fmt.Println("Server started. Listening on localhost:8080...")

	// 接收客户端连接
	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Fatal(err)
		}

		// 启动一个并发的goroutine来处理连接
		go handleConnection(conn)
	}
}

// 处理连接
func handleConnection(conn net.Conn) {
	defer conn.Close()

	// 读取固定长度的数据
	fixedLength := 20 // 假设要读取的数据固定长度为20字节
	buffer := make([]byte, fixedLength)

	_, err := conn.Read(buffer)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Received data: %s\n", string(buffer))

	// 可以在这里对接收到的数据进行处理和响应
	// ...

	// 发送响应给客户端
	response := "Hello, Client!"
	_, err = conn.Write([]byte(response))
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Response sent successfully!")
}
 

客户端

package main

import (
    "fmt"
    "log"
    "net"
)

func main() {
    // 建立TCP连接
    conn, err := net.Dial("tcp", "localhost:8080")
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    // 发送固定长度的数据
    message := "Hello, Server!"
    fixedLength := 20 // 假设要发送的数据固定长度为20字节

    // 如果消息长度小于固定长度,则使用空字符填充
    if len(message) < fixedLength {
        padding := make([]byte, fixedLength-len(message))
        message += string(padding)
    }

    _, err = conn.Write([]byte(message))
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("Data sent successfully!")
}
 

分隔符

发送端在每个包的末尾使用固定的分隔符,例如\n

服务端

package main

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

func main() {
    // 监听指定的TCP端口
    listener, err := net.Listen("tcp", "localhost:8080")
    if err != nil {
        log.Fatal(err)
    }
    defer listener.Close()

    fmt.Println("Server started. Listening on localhost:8080...")

    // 接收客户端连接
    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Fatal(err)
        }

        // 启动一个并发的goroutine来处理连接
        go handleConnection(conn)
    }
}

// 处理连接
func handleConnection(conn net.Conn) {
    defer conn.Close()

    reader := bufio.NewReader(conn)

    for {
        // 读取一行数据,以分隔符"\n"作为结束标志
        message, err := reader.ReadString('\n')
        if err != nil {
            log.Println(err)
            break
        }

        // 去除消息中的换行符
        message = strings.TrimRight(message, "\n")

        fmt.Printf("Received message: %s\n", message)

        // 可以在这里对接收到的消息进行处理和响应
        // ...

        // 发送响应给客户端
        response := "Hello, Client!\n"
        _, err = conn.Write([]byte(response))
        if err != nil {
            log.Println(err)
            break
        }
    }

    fmt.Println("Connection closed.")
}
 

客户端

package main

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

func main() {
    // 建立TCP连接
    conn, err := net.Dial("tcp", "localhost:8080")
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()

    reader := bufio.NewReader(os.Stdin)

    for {
        // 读取用户输入的消息
        fmt.Print("Enter message: ")
        message, err := reader.ReadString('\n')
        if err != nil {
            log.Println(err)
            break
        }

        // 发送消息给服务器
        _, err = conn.Write([]byte(message))
        if err != nil {
            log.Println(err)
            break
        }

        // 读取服务器的响应
        response, err := bufio.NewReader(conn).ReadString('\n')
        if err != nil {
            log.Println(err)
            break
        }

        fmt.Printf("Server response: %s", response)
    }

    fmt.Println("Connection closed.")
}
 

消息长度前缀

将消息分为头部和消息体,头部中保存整个消息的长度,只有读取到足够长度的消息之后才算是读到了一个完整的消息;

代码实现

package main

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

const headerSize = 4 // 头部长度的字节数

func main() {
	// 启动服务器
	go startServer()

	// 连接到服务器
	conn, err := net.Dial("tcp", "localhost:8080")
	if err != nil {
		fmt.Println("连接服务器失败:", err)
		return
	}
	defer conn.Close()

	// 发送消息
	message := "Hello, Server!"
	sendMessage(conn, message)

	// 读取服务器响应
	response, err := readMessage(conn)
	if err != nil {
		fmt.Println("读取消息失败:", err)
		return
	}
	fmt.Println("服务器响应:", response)
}

func startServer() {
	listener, err := net.Listen("tcp", ":8080")
	if err != nil {
		fmt.Println("启动服务器失败:", err)
		return
	}
	defer listener.Close()

	fmt.Println("服务器已启动,等待连接...")

	for {
		conn, err := listener.Accept()
		if err != nil {
			fmt.Println("接受连接失败:", err)
			return
		}
		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	fmt.Printf("客户端 %s 已连接\n", conn.RemoteAddr().String())

	defer conn.Close()

	// 读取消息
	message, err := readMessage(conn)
	if err != nil {
		fmt.Println("读取消息失败:", err)
		return
	}
	fmt.Println("收到消息:", message)

	// 发送响应
	response := "Hello, Client!"
	sendMessage(conn, response)
}

func sendMessage(conn net.Conn, message string) error {
	// 计算消息长度
	messageLength := len(message)

	// 将消息长度写入头部
	header := make([]byte, headerSize)
	binary.BigEndian.PutUint32(header, uint32(messageLength))
	if _, err := conn.Write(header); err != nil {
		return fmt.Errorf("写入消息头部失败: %v", err)
	}

	// 写入消息体
	if _, err := conn.Write([]byte(message)); err != nil {
		return fmt.Errorf("写入消息体失败: %v", err)
	}

	return nil
}

func readMessage(conn net.Conn) (string, error) {
	// 读取消息头部
	header := make([]byte, headerSize)
	if _, err := io.ReadFull(conn, header); err != nil {
		return "", fmt.Errorf("读取消息头部失败: %v", err)
	}

	// 解析消息长度
	messageLength := binary.BigEndian.Uint32(header)

	// 读取消息体
	message := make([]byte, messageLength)
	if _, err := io.ReadFull(conn, message); err != nil {
		return "", fmt.Errorf("读取消息体失败: %v", err)
	}

	return string(message), nil
}

  • 这段代码中,我们启动了一个TCP服务器,等待客户端连接。客户端在连接成功后,发送消息给服务器,服务器接收到消息后,返回一个响应。
  • 在发送消息时,我们首先计算消息的长度,并将长度以4字节的大端字节序写入到头部。然后,将消息体写入

总结

  • TCP 不管发送端要发什么,都基于字节流把数据发到接收端。这个字节流里可能包含上一次想要发的数据的部分信息。接收端根据需要在消息里加上识别消息边界的信息。不加就可能出现粘包问题
  • UDP 是基于数据报的传输协议,每个数据报都是独立传输的(接收端一次只能接受一条独立的消息),不会有粘包问题。

参考

  • 硬核图解|tcp为什么会粘包?背后的原因让人暖心

你可能感兴趣的:(Golang,tcp/ip,golang,网络)