Java网络编程,使用UDP实现TCP(一), 基本实现三次握手

简介:

由于作者在几天前和老师讨论起计算机网络中UDP和TCP协议具体内容,被老师狠狠质疑了,于是怀揣着不服的心情,决定用UDP实现一下TCP,一方面帮助自己更加深刻学习TCP与UDP一方面是打破质疑!“哥们儿真行吧,家人”。

言归正传,首先我们需要知道TCP传输和UDP传输的区别,UDP相当于只管发送不管对方是否接收到了,而TCP相当于打电话,需要进行3次握手,4次挥手,所以我们就需要在应用层上做一些功能添加,如:

  • 增加ack机制

  • 增加seq机制

  • 增加超时重传机制

  • 增加MTU机制

  • 增加数据校验机制

即可实现简单的用UDP实现TCP功能。

part1:了解Java网络编程如何实现UDP和TCP

UDP:

UDP客户端发送数据:

  • 创建UDP套接字:使用DatagramSocket类创建一个UDP套接字,用于发送和接收UDP数据报。可以指定端口号或让系统自动分配一个可用端口。

  • 准备发送的数据,转成字节数组。

  • 构造UDP数据报:创建一个DatagramPacket对象,用于封装要发送的数据和目标主机的信息。需要提供要发送的数据的字节数组、数据的长度、目标主机的IP地址和端口号。

  • 发送数据报:使用UDP套接字的send()方法将封装好的数据报发送给目标主机。该方法接受一个DatagramPacket对象作为参数。

  • 关闭套接字:使用UDP套接字的close()方法关闭套接字,释放相关的资源。

import java.io.IOException;
import java.net.*;

public class UDPClient {
    public static void main(String[] args) throws IOException {
        System.out.println("发送启动中。。。");
        
        //1. 使用 DatagramSocket(8888)
        DatagramSocket datagramSocket = new DatagramSocket(8888);

        //2. 准备数据,一定要转成字节数组
        String data = "hello java";
        //创建数据,并把数据打包
        byte[] datas = "hello java".getBytes();
        DatagramPacket datagramPacket = new DatagramPacket(datas, 0,datas.length, new InetSocketAddress("localhost",9999));

        //调用对象发送数据
        datagramSocket.send(datagramPacket);

        //关闭流
        datagramSocket.close();
    }
}

 UDP服务端接收数据:

  • 创建UDP套接字:使用DatagramSocket类创建一个UDP套接字,用于发送和接收UDP数据报。可以指定端口号或让系统自动分配一个可用端口。

  • 创建一个字节数组用于接收发送的数据。

  • 构造UDP数据报:创建一个DatagramPacket对象,用于封装要发送的数据和目标主机的信息。需要提供要发送的数据的字节数组、数据的长度、目标主机的IP地址和端口号。

  • 发送数据报:使用UDP套接字的receive()方法将封装好的数据报发送给目标主机。该方法接受一个DatagramPacket对象作为参数。

  • 关闭套接字:使用UDP套接字的close()方法关闭套接字,释放相关的资源。

package TCP_UDP_Practice.UDPrecieve;

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;

public class UDPClient {
    public static void main(String[] args) throws IOException {
        System.out.println("接收方接收中。。。");
        DatagramSocket datagramSocket = new DatagramSocket(9999);
        byte[] container = new byte[1024 * 60];
        DatagramPacket packet = new DatagramPacket(container, 0, container.length);
        datagramSocket.receive(packet);
        System.out.println(new String(packet.getData(), 0, packet.getLength()));
        datagramSocket.close();
    }
}

TCP:

TCP客户端发送数据:

  • 创建TCP客户端套接字:在服务器接受到客户端的连接请求后,将创建一个新的TCP套接字,用于和客户端进行通信。服务器套接字和客户端套接字之间建立了一条连接。

  • 数据传输:使用客户端套接字和服务器套接字进行数据传输。可以使用输出流来写入数据。服务器套接字和客户端套接字之间可以进行双向的数据传输。

  • 关闭连接:当数据传输完成或需要关闭连接时,可以调用套接字的close()方法关闭连接。关闭连接后,服务器套接字将继续监听新的连接请求。

package TCP_UDP_Practice.TCPsendMsg;

import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;

public class ClientDemo {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1", 10005);
        //创建输入流对象,写入数据
        OutputStream outputStream = socket.getOutputStream();
        outputStream.write("hello tcp".getBytes());
        //关闭流
        socket.close();
    }
}

TCP服务端接收数据:

  • 创建TCP服务器套接字:使用ServerSocket类创建一个TCP服务器套接字,用于监听客户端的连接请求。需要指定服务器的端口号。
  • 数据传输:使用客户端套接字和服务器套接字进行数据传输。可以使用输入流来读取数据。服务器套接字和客户端套接字之间可以进行双向的数据传输。

  • 关闭连接:当数据传输完成或需要关闭连接时,可以调用套接字的close()方法关闭连接。关闭连接后,服务器套接字将继续监听新的连接请求。

package TCP_UDP_Practice.TCPrecieve;

import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class ServerDemo {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(10005);

        Socket accept = serverSocket.accept();

        //获取输入流
        InputStream inputStream = accept.getInputStream();
        byte[] bytes = new byte[1024];
        int read = inputStream.read(bytes);
        String s = new String(bytes, 0, read);
        System.out.println("数据是:" + s);

        //关闭流
        serverSocket.close();
    }
}

Part2:用UDP如何实现TCP的三次握手?

参考《TCP/IP详解》卷一的424页,我们可以得知三次握手须传输的主要数据有SYN, Seq和ACK,接下来我将详细说说三次握手这些数据有何变化,如何获取。

第一次握手:

  • 客户端会发送一个SYN 报文段(即一个在TCP头部位置SYN位置的TCP/IP数据包),并指明自己想要连接到的端口号和它的客户端初始序列号ISN。客户端发送的这个SYN报文段称为段1。
  •  那么问题来了:SYN,ISN到底如何获取,如何用Java程序写出来呢?
    • SYN:(Synchronize)是TCP(传输控制协议)中的一个标志位,用于建立连接的过程中进行同步。在TCP三次握手的过程中,SYN用于表示发起连接请求的一方(通常是客户端)希望建立连接。SYN标志位的值为1,表示发起连接请求或确认连接请求。
    • Seq:(Sequence Number)是用于标识数据字节顺序的字段。每个TCP报文段都包含一个Seq字段,用于指示报文段中的数据字节在整个数据流中的位置。

      • Seq字段的值表示报文段中的第一个数据字节的序列号。每个字节都有一个唯一的序列号,序列号从一个初始值开始,并随着每个传输的字节递增。

      • 在TCP连接建立后,双方会通过ISN(Initial Sequence Number)来初始化序列号。ISN是一个随机选择的32位无符号整数,用作初始的序列号。之后,发送方在发送数据时,会为每个报文段分配一个递增的序列号。

      • 接收方在接收到报文段时,根据Seq字段的值来确定数据字节的顺序。如果接收方发现某个报文段的Seq值不连续或重复,它会通知发送方进行相应的处理,以确保数据的正确传输和重组。

      • Seq字段的作用是保证TCP数据的有序性和可靠性。通过正确的序列号,接收方可以按正确的顺序重组数据,并检测丢失或重复的数据。

      • 需要注意的是,Seq字段的范围是32位无符号整数,因此序列号会在达到最大值后重新从0开始循环。

    • ISN:(Initial Sequence Number)是TCP(传输控制协议)中用于初始化序列号的值。序列号用于标识TCP报文段中的数据字节顺序,以便接收方可以按正确的顺序重组数据。

      在TCP连接建立时,双方需要协商一个初始的序列号。

      • ISN是一个随机选择的32位无符号整数,通常由操作系统生成。ISN的选择是为了增加连接的安全性,防止恶意攻击者猜测序列号并插入伪造的数据。

      • ISN的选择是根据一些算法和系统状态进行的,具体的实现可能因操作系统而异。通常,ISN的选择会考虑到时间、IP地址、端口号等因素,以确保序列号的唯一性和随机性。在[RFC1948]中提出了一个较好的初始化序列号ISN随机生成算法。ISN = M + F(localhost, localport, remotehost, remoteport). 

        注意:M是一个计时器,这个计时器每隔4毫秒加1。F是一个Hash算法,根据源IP、目的IP、源端口、目的端口生成一个随机数值。要保证hash算法不能被外部轻易推算得出,用MD5算法是一个比较好的选择。
      • 一旦双方在三次握手过程中成功建立连接,ISN就会被用作初始的序列号,并在后续的数据传输中递增。序列号的递增是为了确保数据的有序传输和重组。

      • 需要注意的是,ISN是每个TCP连接独立选择的,不同的连接会有不同的ISN。这样可以避免一个连接中的序列号被用于另一个连接,从而增加连接的安全性。

ISN初始化代码如下:

package TCP_handShake;

import java.time.LocalDateTime;
import java.util.UUID;

/**
 * 初始化Seq的值ISN
 * RFC1948中提出了一个较好的初始化序列号ISN随机生成算法:
 * ISN = M + F(localhost, localport, remotehost, remoteport).
 *
 */
public class initializeISN {
    private int ISN = generateISN() ;

    public int getISN() {
        return ISN;
    }

    private int generateISN(){
        // 获取当前时间
        String currentTime = String.valueOf(LocalDateTime.now().getSecond());

        // 生成UUID
        UUID uuid = UUID.randomUUID();

        // 将时间和UUID结合生成ISN
        String isnString = currentTime + uuid.toString();
        int isn = isnString.hashCode();

        return isn;
    }
}


在我的代码中,由于我的目的是简单的实现,所以并未采用 [RFC1948]提到的算法,而是使用当前时间的秒数(通过LocalDateTime类得到)和UUID进行字符串拼接,实现了唯一性。(由于没有做到后面的内容,如后续如发现有问题,会进行更改

SYN和Seq初始化代码如下

package TCP_handShake;

/**
 * 标志位 connectionMarks
 */
public class ConnectionMarks extends initializeISN{
    //每次建立新连接,将SYN初始化为1
    private int SYN;
    //获取ISN
    private int Seq;

    public ConnectionMarks() {
        this.SYN = 1;
        this.Seq = getISN();
    }


    public int getSeq() {
        return Seq;
    }


    //setter of SYN
    public Integer getSYN() {
        return SYN;
    }
}

第一次握手客户端发送数据:

 System.out.println("第一次握手:");
        System.out.println("正在发送SYN和Seq......");

        //1. 使用 DatagramSocket(8888)
        DatagramSocket datagramSocket = new DatagramSocket(8888);
        ConnectionMarks connectionMarks = new ConnectionMarks();
        String SYN = String.valueOf(connectionMarks.getSYN());
        //getSeq() 方法值等同于 getISN(),获取ISN(c)
        int ISN1 = connectionMarks.getSeq();
        String Seq = String.valueOf(ISN1);

        //2. 准备数据,一定要转成字节数组
        String data = SYN + " " + Seq;
        //创建数据,并把数据打包
        byte[] datas = data.getBytes();
        DatagramPacket datagramPacket = new DatagramPacket(datas, 0,datas.length, new InetSocketAddress("localhost",9999));

        //调用对象发送数据
        datagramSocket.send(datagramPacket);
        //关闭流
        datagramSocket.close();

第一次握手服务端接收数据:

  System.out.println("接收数据:...");
        //创建接收端对象
        DatagramSocket datagramSocket = new DatagramSocket(9999);

        //创建数据包,用于接收数据
        byte[] bytes = new byte[1024];
        DatagramPacket datagramPacket = new DatagramPacket(bytes, bytes.length);

        datagramSocket.receive(datagramPacket);
        String s = new String(datagramPacket.getData(), 0, datagramPacket.getLength());

        //解析数据包并且输出显示
        System.out.println("数据为: " + s);
        //关闭流
        datagramSocket.close();

第二次握手:

  1. 服务端收到客户端的SYN包(SYN=j)后,需要回复一个SYN+ACK的包给客户端。
  2. 这个SYN+ACK的包里,ACK的值为j+1,表示"我已经收到你的SYN了"。
  3. 同时,服务端也会发送自己的SYN包,序列号为ISN(s),这个序列号是服务端自己生成的。

服务端在第二次握手中发送的包,其SYN和ACK标志位都被设置为1(SYN+ACK),序列号(Seq)为服务端自己生成的初始序列号(ISN(s)),确认号(ACK)为客户端的初始序列号加1(ISN(c)+1)。

注意:此处ACK为一个flag标志位,只是说明得到了ACK

在connectionMark类补充ACKMark的初始化

package TCP_handShake;

/**
 * 标志位 connectionMarks
 */
public class ConnectionMarks extends initializeISN{
    //每次建立新连接,将SYN初始化为1
    private int SYN;
    //随机
    private int Seq;

    private  int ACKMark;

    public int getACKMark() {
        return ACKMark;
    }

    public void setACKMark(int ACKMark) {
        this.ACKMark = ACKMark;
    }

    public ConnectionMarks() {
        this.SYN = 1;
        this.Seq = getISN();
        this.ACKMark = 0;
    }


    public int getSeq() {
        return Seq;
    }


    //setter of SYN
    public Integer getSYN() {
        return SYN;
    }
}

第二次握手服务端发送数据:

System.out.println("====================");
        System.out.println("第二次握手:");
        System.out.println("正在发送SYN, Seq 和 ACK......");

        ConnectionMarks connectionMarks = new ConnectionMarks();
        //第二次握手,返回ACK = ISN + 1;
        //生成自己的ISN(s)
        String Seq2 = String.valueOf(connectionMarks.getSeq());
        //ACK2中的ISN为第一次传过来的ISN(c)+1
        String ACK2 = String.valueOf(ISN1+ 1);
        //将ack标志位设为1
        connectionMarks.setACKMark(1);
        String SYN2 = connectionMarks.getSYN() + "/" + connectionMarks.getACKMark();

        //2. 准备数据,一定要转成字节数组
        String data2 = SYN2 + " " + Seq2 + " " + ACK2;

        //创建数据,并把数据打包
        byte[] datas2 = data2.getBytes();
        DatagramPacket datagramPacket2 = new DatagramPacket(datas2, 0,datas2.length, new InetSocketAddress("localhost",8888));

        //调用对象发送数据
        datagramSocket.send(datagramPacket2);

第二次握手客户端接收数据:

System.out.println("====================");
        System.out.println("接收数据:...");
        //创建数据包,用于接收数据
        /**
         * 在第二次握手中,客户端主要会检查两个方面的内容:
         * 检查ACK标志位:客户端需要确认服务端发送的确认信息(SYN-ACK)中的ACK标志位是否已设置。ACK标志位表示服务端确认收到了客户端的握手请求。
         * 检查确认号(ACK):客户端需要检查服务端发送的确认信息中的确认号(ACK)是否正确。确认号应该是服务端发送的初始序列号加1,用于告知服务端它已经正确接收到服务端的数据。
         */
        byte[] bytes = new byte[1024];
        DatagramPacket datagramPacket2 = new DatagramPacket(bytes, bytes.length);
        datagramSocket.receive(datagramPacket2);
        String s = new String(datagramPacket2.getData(), 0, datagramPacket2.getLength());


        //拆分字符串获取其中的SYN,Seq和ACK
        String[] strArr = s.split(" ");
        String[] flag = strArr[0].split("/");
        //System.out.println(strArr[0]);
        //检验接收信息是否是满足需求的
        if (!(Integer.parseInt(flag[1]) != 0
                        && Integer.parseInt(flag[0]) == 1
                        && Integer.parseInt(strArr[2]) == ISN1 + 1)
        ){
            //TODO 异常提醒,非本次连接,如何处理
            throw new RuntimeException("wrong connection");
        }
        System.out.println("通过校验");
        //解析数据包并且输出显示
        System.out.println("数据为: " + s);

注意:第一次握手服务端不需要进行校验,但是第二次握手用户端就需要进行校验,ACK标志位是否为1,ACK值是否为ISN(c)+1,SYN值是否为1。

第三次握手

第三次握手,客户端会发送以下三个数据:

  1. ACK标志位应该为1,表示确认收到第二次握手客户端发来的消息。
  2. Seq,值和第二次握手服务端传来的ACK相同
  3. ACK值,为第二次握手服务端传来的ISN(s)+1

第三次握手客户端发送数据:

System.out.println("====================");
        //第三次握手
        System.out.println("第三次握手:");
        System.out.println("正在发送SYN, Seq 和 ACK......");
        connectionMarks.setACKMark(1);
        String ackMark = String.valueOf(connectionMarks.getACKMark());
        String Seq3 = strArr[2];
        String ACK3 = String.valueOf(Integer.parseInt(strArr[1]) + 1);
        //2. 准备数据,一定要转成字节数组
        String data3 = ackMark + " " + Seq3 + " " + ACK3;
//        System.out.println("+++++++++++++++++");
//        System.out.println(ACK3);
        //创建数据,并把数据打包
        byte[] datas3 = data3.getBytes();
        DatagramPacket datagramPacket3 = new DatagramPacket(datas3, 0,datas3.length, new InetSocketAddress("localhost",9999));

        //调用对象发送数据
        datagramSocket.send(datagramPacket3);

第三次握手服务端接收数据

       服务端接收数据,需要检查ACK标志位是否为1,ACK内容是否为ISN(s)+1。

 System.out.println("====================");
        System.out.println("接收数据:...");

        //创建数据包,用于接收数据
        byte[] bytes3 = new byte[1024];
        DatagramPacket datagramPacket3 = new DatagramPacket(bytes3, bytes3.length);

        datagramSocket.receive(datagramPacket3);
        String s3 = new String(datagramPacket3.getData(), 0, datagramPacket3.getLength());

        //解析数据包并且输出显示
        System.out.println("数据为: " + s3);

        //拆分字符串获取其中的SYN,Seq和ACK
        String[] strArr3 = s.split(" ");

        //System.out.println(strArr[0]);
        //检验接收信息是否是满足需求的
        if (Integer.parseInt(strArr3[0]) != 1){
            //TODO 异常提醒,非本次连接,如何处理
            throw new RuntimeException("wrong connection");
        }
        System.out.println("通过校验,完成三次握手");

初步总结:

至此完成了简单的三次握手,但是并没有实现超时重传机制,MTU输入缓冲。后续会进行完善和修改,全部代码会在我完成整个TCP通信流程后,开源到GitHub,由于作者能力有限可能有一些错误还烦请大家指出来,我会第一时间进行反思和修改,感谢。

GitHub:

UDP-based-TCP-implementation/ at master · Benaso/UDP-based-TCP-implementation (github.com)

参考资料:

巨人的肩膀:

  1. 《TCP/IP详解》卷一,卷二
  2. 《图解TCP/IP》
  3. 使用UDP实现TCP协议 代码示例 - pluto_charon - 博客园 (cnblogs.com)
  4. 【TCP通信】原理详解与编程实现(一)_tcp通讯-CSDN博客
  5. 怎么用 UDP 实现 TCP?_udp转tcp-CSDN博客
  • 26
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Benaso

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值