Java网络编程,使用UDP实现TCP(一), 基本实现三次握手
简介:
首先我们需要知道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();
第二次握手:
- 服务端收到客户端的SYN包(SYN=j)后,需要回复一个SYN+ACK的包给客户端。
- 这个SYN+ACK的包里,ACK的值为j+1,表示"我已经收到你的SYN了"。
- 同时,服务端也会发送自己的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。
第三次握手
第三次握手,客户端会发送以下三个数据:
- ACK标志位应该为1,表示确认收到第二次握手客户端发来的消息。
- Seq,值和第二次握手服务端传来的ACK相同
- 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);
第三次握手服务端接收数据
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,由于作者能力有限可能有一些错误还烦请大家指出来,我会第一时间进行反思和修改,感谢。