面向无连接的UDP
UDP是一种面向无连接的传输协议,可以理解为对某个地址寄一封信,只要双方地址填对,信就可以成功送出去,但对方能不能收到这份信,那就不一定了,可能信在传输的过程中丢了,也有可能接收方没有去查收它。总之,UDP不可靠。那么什么是可靠的呢?TCP,TCP是一种面向连接的通信协议,它在建立连接前会通过三次握手来确认双方都可以正确接收信息,面向连接的通信方式可以理解为打电话的过程,只有双方都在线才能通话。那么既然TCP可以面向连接实现通信,为什么不直接使用TCP而放弃UDP呢?UDP自然有它的优点。UDP可以不用实时地建立连接,在没有数据的时候不会占用资源,而TCP需要保持一种稳定地连接状态,UDP明显比TCP更加灵活高效一些,但是它会丢包,为了解决丢包等问题,就需要人为编写协议来约束这个过程了。
我们的协议设计
为了更好地解决UDP的不可靠性带来的问题,又充分利用它的优点,实现一种可以被我们代码灵活控制的传输,我和小伙伴一起设计了一份协议。这份协议主要针对文件的传输,确保文件传输过程的可靠性,在之后可以继续扩展到传输别的内容上。
文件传输的三个过程:
①三次握手(双方确认连接)
②传文件信息(文件的名称,字节数等信息)
③传文件内容(读取本地文件后得到的字节数组)

为了满足文件传输过程的标准化,我们设计了一种数据包的格式,它可以应用于三个传输过程,分别用不同的类型号来区别,同时它有可以灵活地用于其他类型数据的传输,具有可扩展性。
数据包的结构设计:

数据包各部分的作用:
1、¥和@标识符:数据包的头部和尾部分别有一个“¥”和“@”符号,它可以用来表示数据包的开头和结尾。当传输的数据量比较大的时候,路由器会对数据包进行分解,使得其符合窗口的大小,最后在接收端得到的可能就是零零散散的数据包了这时标识符就起了作用,它可以判断这个包是否完整,因为包的总长是确定的1024,那么一个被拆分的数据包的前半段就可以通过长度去匹配丢失的下半段,接收方找到被拆开的包的两个部分后就可以把它们合并起来了。
2、类型号:因为我们的通信过程包括三个部分,虽然使用相同的数据包模板,但其中数据的存储形式与读取的需求是不同的,接收方需要根据不同的类型号来判断这个数据包是三次握手的数据包还是文件信息内容的数据包。类型号的设计思路使得在同一对端口传输不同类型数据包成为了可能。
3、包编号:一个文件往往需要拆解为大量的数据包来进行传输,包编号就是数据段的一个身份凭证,接收方可以根据编号来恢复文件内容,发送方也可以根据包编号来判断重传。包编号的我们用到了4个字节,是考虑到文件比较大的时候包的数量会很多,4个字节可以承载这些数量。
4、预留:预留部分是为了增加代码的扩展性而设计的,在这个过程中,我们发现需要为服务器和客户端建立一个文件的序列号,这个序列号是在服务器分配文件任务的时候创建的,在多个用户同时向服务器传输文件的时候,服务器可以通过文件序列号来区分,这个文件序列号就规定在了预留1,。后续的预留字节可以根据需要来安排。
数据包的类设计:
数据包对象包含的信息有编号,被重传的次数,目标地址等信息(就像是一个快递包裹)。
/**
* 数据包类
* @author mayifan
*
*/
class Packet{
public int number;//包的编号
public int reSendTimes;//重传次数
public byte[] data;//存放的数据
public long lastTime;//最后一次发送的时间
public String destIp;//目标IP
public int destPort;//目标端口号
public Packet(int number,byte[] data,String destIp,int destPort){
this.number=number;
this.data=data;
this.destIp=destIp;
this.destPort=destPort;
this.reSendTimes=0;
this.lastTime=System.currentTimeMillis();
}
}
三次握手,建立可靠连接
三次握手图示:

三次握手的数据包结构:数据包需要标明类型号0,表示是三次握手过程;预留1处明确文件在服务器端的序号(在第一次接受服务器反馈时获取,以后每次都要带上);内容部分的最后两个字节,分别存放seq和ack,seq是序列号,而ack表示应答号。
三次握手的目的:为了让服务器和客户端都知道对方可以接收到自己的信息,为了后续的传输提供安全可靠的前提。
三次握手的过程:客户端向服务器发送自己的序列号,假设是1,服务器接收到客户端是序列号后把客户端的序列号加一得到2作为应答号发送,同时发送自身的序列号5;客户端在收到后再把服务器的序列号5加1得到6作为应答号发还给服务器。这样双方就可以确保对方能够收到自己的消息,再加上重传机制就没问题了。
字节的赋值方式:这里字节的赋值思路和大家简单提一下,因为Java中的数是有符号的,因此类似buffer[1]=0的形式是把int数值转为字节中八位二进制对应的值,单字节的赋值范围是(-128~127),如果等号右侧的值在范围外,那么是会有错误提示的;如果写一个int变量,需要强转为byte类型。总之,需要考虑符号。代码中涉及到的不同位数的字节数组和int值相互转化的方法都写在Tools工具类中。
客户端生成三次握手数据包的代码:
输入seq和ack的值就可以返回数据包的字节数组了,在这个方法里有字节数组的生成过程(为各个字节赋值)。
/**
* 生成包裹对应的字节数组
* 输入序列号和应答号
* @return
*/
public byte[] getConnectString(int first,int second){
byte buffer[] =new byte[1024];//定义一个空的字节数组
buffer[0]='$';//文件头¥
buffer[1]=0;//类型0
for(int i=2;i<6;i++){ //包编号
buffer[i]=0;
}
buffer[6]=(byte)fileNumber;//预留1
buffer[7]=0;//预留的剩余两个字节
buffer[8]=0;
for(int i=10;i<1021;i++){//内容1
buffer[i]=0;
}
buffer[1021]=(byte)first;//自身的序列号1,应答号部分为0;第一个是seq位,第二个位是ack位
buffer[1022]=(byte)second;;
buffer[1023]='@'; //结尾部分,一个字节
return buffer;
}
三次握手的方法:
这是三次握手的方法,其中的sendMessage方法是发送数据包(字节数组)的方法,这里不展开。这里包括了发数据包,收数据包,读取信息,发回应包的过程。
/**
* 三次握手,客户端和服务器建立连接
*/
public void threeHandShake(FileMessage fileMessage){
try{
Log.v("MainActivity", "开始一次握手");
//一次握手
byte[] connect=getConnectString(fileMessage.getSerialNumber(),0);//获取第一次握手的字节数组
sendMethod.sendMessage(connect, fileMessage.getDestIp(), fileMessage.getDestPort());//发10,第一次握手
reSendThread.addPacket(new Packet(0, connect, fileMessage.getDestIp(), fileMessage.getDestPort()));//把它放到重发的线程中
Log.v("MainActivity", "开始二次握手");
//二次握手
byte[] data=getMethod.getMessage();//获取得到的字符串
reSendThread.removePacket(0);//得到了应答,在重传队列中移除数据包
updateUsefulNumber(data);//更新两个部分的数据
int second=fileContent[0]+1;//读到服务器的序列号并加一
int first=fileContent[1];//服务器的应答号
Log.v("MainActivity", "first:"+first+"");
Log.v("MainActivity", "second:"+second+"");
Log.v("MainActivity", "fileContent[1]:"+fileContent[1]);
if(fileContent[1]!=fileMessage.getSerialNumber()+1){
throw new Exception("没有收到自身序列号加一");//如果没有收到正确的应答,则抛出异常
}
Log.v("MainActivity", "开始三次握手");
//三次握手
connect=getConnectString(first,second);//获取第三次握手的字符串
sendMethod.sendMessage(connect,fileMessage.getDestIp(),fileMessage.getDestPort());//发送应答号给服务器,表示已经收到了服务器的序列号
}catch(Exception e){
Log.v("MainActivity","三次握手出现了异常");
e.getMessage();
}
}
重传的代码实现:
三次握手每个阶段都采用了数据包的重传,重传的线程如下,它内部维护一个队列,这个线程负责数据包的重传,每隔一段时间重传。重传三次后移除对应数据包,或者当收到应答后,外界调用了removePacket方法移除队列中的数据包。这个线程对外提供添加数据包和删除数据包的方法。
/**
* 负责数据包重传的线程
* @author mayifan
*
*/
public class ReSendThread extends Thread{
private SendMethod sendMethod;
private ArrayList<Packet> packetList;
/**
* 线程的构造方法
* 线程开启后一直有效
* @param sendMethod
*/
public ReSendThread(SendMethod sendMethod){
this.sendMethod=sendMethod;
packetList=new ArrayList<Packet>();//实例化队列
}
/**
* 添加重传队列的数据包
* @param packet
*/
public void addPacket(Packet packet){
packetList.add(packet);
}
/**
* 移除指定编号的数据包
* @param number
*/
public void removePacket(int number){
for(int i=0;i<packetList.size();i++){
if(packetList.get(i).number==number){
packetList.remove(i);
}
}
}
/**
* 判断重传列表是否为空
* @return
*/
public boolean isEmpty(){
if(packetList.size()==0){
return true;
}else{
return false;
}
}
public void run(){
try{
while(true){
Thread.sleep(100);//刚开始睡眠等待接收
for(int i=0;i<packetList.size();i++){
if(packetList.get(i)!=null){
Packet packet=packetList.get(i);
long time=System.currentTimeMillis()-packet.lastTime;//距离上次发送的时间
if(time>300){
sendMethod.sendMessage(packet.data, packet.destIp,packet.destPort);//超出300ms则重发
Log.v("MainActivity", "重发了一个数据包,它的编号是:"+packet.number);
packet.reSendTimes++;
}
if(packet.reSendTimes>=3){ //发送超过三次就移除
packetList.remove(i);
}
Thread.sleep(30);//每次数据包处理之间的间隔
}
}
}
}catch (Exception e) {
Log.v("MainActivity", "重传线程异常");
e.printStackTrace();
}
}
}
发送文件的信息
文件的信息包括:文件名,包数(拆得的数据包数量),文件总字节数(读取本地文件后的字节数组长度),补零数(在拆包中最后一个数据包的补零数),文件在服务端的编号(用于在服务端区分不同任务)。
文件信息在数据包中的组织形式:
图片如下。文件信息是放在内容的区域的,且靠后放。各个部分占用的字节数如图所示,其中文件名的长度是由表示文件名字节数的那个字节来决定的,读取的时候也是先读取那个字节,再决定文件名读多长。这里是类型号是1,表示这是发文件信息的过程,预留1是从服务器得到的文件序号。

如何发送文件信息:依然是向同一个服务器端口发数据包,这个文件信息数据包也采用了重传机制来保证可靠性,只要把数据包发出去,然后把它放到重传线程中就可以了。
文件信息数据包的字节数组生成代码:
和上述的生成三次握手数据包的方式类似,只是这里需要赋值的地方更多一些。
/**
* 生成文件信息的字节数组
* @param fileMessage
* @return
*/
public byte[] getFileMessageString(FileMessage fileMessage)throws Exception{
byte buffer[] =new byte[1024];//定义一个空的字节数组
//文件头¥
buffer[0]='$';
//类型1
buffer[1]=1;
//包的编号
for(int i=2;i<6;i++){
buffer[i]=0;
}
//预留字节
buffer[6]=(byte) fileNumber;//预留1,存放文件在服务器端的号码
buffer[7]=0;//预留的剩余两个字节
buffer[8]=0;
//无效的内容信息
for(int i=10;i<1010-fileMessage.getName().length();i++){ //内容1
buffer[i]=0;
}
//文件名称 ,长度动态
byte[] getByte=Tools.get2Byte(fileMessage.getName());//把文件名称转为字节数组
for(int i=1010-getByte.length;i<1010;i++){ //为文件名称部分赋值
buffer[i]=getByte[i-1010+getByte.length];
}
buffer[1010]=(byte)(fileMessage.getName().length()*2);//文件名称占用的字节数
//文件包数,4个字节
byte[] fourByte=Tools.intTo4Byte(fileMessage.getPacketNumber());//把总字节数转为5个字节
for(int i=1011;i<1015;i++){
buffer[i]=fourByte[i-1011];//逐位赋值
}
//总字节数,6个字节
byte[] fiveByte=Tools.intTo6Byte(fileMessage.getFileByteSum());//把总字节数转为5个字节
for(int i=1015;i<1021;i++){
buffer[i]=fiveByte[i-1015];//逐位赋值
}
//补零数(表示把多少个字节为了0),两个字节
byte[] get2Byte=Tools.intTo2Byte(fileMessage.getAddZeroNumber());
buffer[1021]=get2Byte[0];
buffer[1022]=get2Byte[1];
//结尾部分,一个字节
buffer[1023]='@';
return buffer;
}
发送文件信息数据包的方法:
这个过程就是先生成数据包的字节数组,然后把数据包发给服务器并考虑重传的过程,一旦接收到应答包,就停止重传。
/**
* 传输文件信息给服务器
*/
public void sendFileMessage(FileMessage fileMessage){
try{
Log.v("MainActivity", "开始传输文件信息给服务器");
byte[] fileMessagePacket=getFileMessageString(fileMessage);//获取文件信息
Log.v("MainActivity", "开始发送message");
sendMethod.sendMessage(fileMessagePacket,fileMessage.getDestIp(), fileMessage.getDestPort());//发送文件信息包裹
reSendThread.addPacket(new Packet(0, fileMessagePacket, fileMessage.getDestIp(), fileMessage.getDestPort()));//存入重传线程
getMethod.getMessage();//获取得到的字符串
reSendThread.removePacket(0);//在重传队列中移除数据包
Log.v("MainActivity", "得到了应答包,停止重发");
}catch(Exception e){
Log.v("MainActivity", "传输文件信息出现了异常");
e.getMessage();
}
}
发送文件的内容
拆包:数据的内容放在“内容”位置,长度是1014字节,也就是说我们需要把文件读取的字节数组除以1014划分到不同的包中,末尾不足1014还需要补零,补零数会在文件信息的传输过程发给服务器,因此需要预先拆包,然后再传文件信息。如下方法读取本地文件,然后拆为若干字节数组存放到ArrayList并返回。
/**
* 通过文件路径获取文件字节数组并拆包,生成若干数据包对象
*/
public ArrayList<Packet> getPacketList(FileMessage fileMessage){
ArrayList<Packet> byteList=new ArrayList<Packet>();
Log.v("MainActivity","到达");
try{
Log.v("MainActivity", fileMessage.getPath());
//从流中获取文件总字节
FileInputStream fis=new FileInputStream(new File(fileMessage.getPath()+".jpg"));
Log.v("MainActivity","到达1");
ByteArrayOutputStream baos=new ByteArrayOutputStream();
byte[] buffer=new byte[1024];
int len=0;
while((len=fis.read())!=-1){
baos.write(buffer, 0, len);
}
byte[] data=baos.toByteArray();
baos.close();
fis.close();
int fileByteSum=data.length;//字节数组总长度
fileMessage.setFileByteSum(fileByteSum);//设置文件字节总数
int packetNumber=fileByteSum/1014+1;//得到总包数
fileMessage.setPacketNumber(packetNumber);//设置文件的数据包总数
int addZeroNumber=packetNumber*1014-fileByteSum;
fileMessage.setAddZeroNumber(addZeroNumber);//设置补零数
//获得1014大小的字节数组,再封装得到1024大小的字节数组,最后得到数据包对象,存入数据包列表
Log.v("MainActivity","到达3");
for(int i=0;i<packetNumber;i++){
if(i!=packetNumber-1){
byte[] buf=new byte[1014];
System.arraycopy(data, i*1014, buf, 0, 1014);//得到一个1014大小的字节数组
byte[] buf2=getPacket(i+1, buf);//封装为1024大小的字节数组
Packet packet=new Packet(i+1, buf2, fileMessage.getDestIp(), fileMessage.getDestPort());//得到数据包对象
byteList.add(packet);
}else{
byte[] buf=new byte[1014];
System.arraycopy(data, i*1014, buf, 0, 1014-addZeroNumber);//得到一个1014大小的字节数组
for(int j= 1014-addZeroNumber;j<1014;j++){
buf[j]=0;
}
byte[] buf2=getPacket(i+1, buf);//封装为1024大小的字节数组
Packet packet=new Packet(i+1, buf2, fileMessage.getDestIp(), fileMessage.getDestPort());//得到数据包对象
byteList.add(packet);
}
}
}catch(Exception e){
Log.v("MainActivity","文件数据读取异常");
e.getMessage();
}
return byteList;
}
生成文件内容数据包的方法:
传入编号和表示内容的1014长度字节数组就可以了。
/**
* 生成文件内容的传输包表示的字节数组
* @param number
* @param buffer
* @return
*/
public byte[] getPacket(int number,byte[] data){
byte buffer[] =new byte[1024];//定义一个空的字节数组
//文件头¥
buffer[0]='$';
//类型2
buffer[1]=2;
//包的编号
byte[] fourByte=Tools.intTo4Byte(number);//把编号转化为4个字节
for(int i=2;i<6;i++){
buffer[i]=fourByte[i-2];
}
//预留字节
buffer[6]=(byte) fileNumber;//预留1,存放文件在服务器端的号码
buffer[7]=0;//预留的剩余两个字节
buffer[8]=0;
//文件数据内容
System.arraycopy(data, 0, buffer, 10, 1014);
//结尾部分,一个字节
buffer[1023]='@';
return buffer;
}
发送文件内容的方法:
这类包裹是类型2,在发送前先移除之前的类型为0和1的数据包。然后开启接受应答包的线程,接收到应答包就把对应编号的数据包在重传队列中移除。然后就是发送这些数据包并分别添加到重传队列了。
/**
* 传输文件具体内容给服务器
* @param fileMessage
*/
public void sendFileContent(ArrayList<Packet> packetList,String destIp,int destPort){
Log.v("MainActivity","开始传输文件内容到服务器");
reSendThread.removePacket(0);//移除所有在重发列表中的编号为0的数据包(0号数据包用于连接)
reSendThread.removePacket(1);//移除所有在重发列表中的编号为1的数据包(1号数据包用于传文件信息)
new MessageGetThread(getMethod,sendMethod, reSendThread,destIp,destPort,fileNumber).start();//开启一个接收应答包的线程,接收到就把数据包从重发队列中移除
try{
for(int i=0;i<packetList.size();i++){
Packet packet=packetList.get(i);
sendMethod.sendMessage(packet.data, destIp, destPort);//发送数据包到服务器
reSendThread.addPacket(packet);//把这个数据包添加到重发线程
}
}catch(Exception e){
Log.v("MainActivity", "传输文件内容出现了异常");
e.getMessage();
}
}
为UDP编写可靠传输协议的小结
在上述协议下,我们完成了三次握手,拆包,传文件信息,读取本地文件,传文件内容,接收数据包并合包,生成服务器端文件的过程,我们试着同时为服务器发送多组文件,结果都可以正确处理和接收。接下来我们还会继续在文件回传,文件分类及管理,Android APP界面设计这些方面研究。上述代码都是客户端(Android端)的代码,服务器代码大家可以参考如下链接:https://zhuanlan.zhihu.com/p/55306241
本文详细介绍了基于UDP协议实现可靠文件传输的过程,包括面向无连接的UDP特性、协议设计、三次握手建立连接、文件信息及内容的发送。通过自定义数据包格式和重传机制,确保了文件传输的可靠性。同时,文中还展示了如何处理文件的拆包、组合以及错误检测与纠正。
842

被折叠的 条评论
为什么被折叠?



