单个TCP(Socket)连接,发送多个文件

使用一个TCP连接发送多个文件

为什么会有这篇博客?
最近在看一些相关方面的东西,简单的使用一下 Socket 进行编程是没有的问题的,但是这样只是建立了一些基本概念。对于真正的问题,还是无能为力。当我需要进行文件的传输时,我发现我好像只是发送过去了数据(二进制数据),但是关于文件的一些信息却丢失了(文件的扩展名)。而且每次我只能使用一个 Socket 发送一个文件,没有办法做到连续发送文件(因为我是依靠关闭流来完成发送文件的,也就是说我其实是不知道文件的长度,所以只能以一个 Socket 连接代表一个文件)。 这些问题困扰了我好久,我去网上简单的查找了一下,没有发现什么现成的例子(可能没有找到吧),有人提了一下,可以自己定义协议进行发送。 这个倒是激发了我的兴趣,感觉像是明白了什么,因为我刚学过计算机网络这门课,老实说我学得不怎么样,但是计算机网络的概念我是学习到了。计算机网络这门课上,提到了很多协议,不知不觉中我也有了协议的概念。所以我找到了解决的办法:自己在 TCP 层上定义一个简单的协议。 通过定义协议,这样问题就迎刃而解了。


协议的作用

从主机1到主机2发送数据,从应用层的角度看,它们只能看到应用程序数据,但是我们通过图是可以看出来的,数据从主机1开始,每向下一层数据会加上一个首部,然后在网络上进行传播,当到达主机2后,每向上一层会去掉一个首部,达到应用层时,就只有数据了。(这里只是简单的说明一下,实际上这样还是不够严谨,但是对于简单的理解是够了。)

在这里插入图片描述
所以,我可以自己定义一个简单的协议,将一些必要的信息放在协议头部,然后让计算机程序自己解析协议头部信息,而且每一个协议报文就相当于一个文件。这样多个协议就是多个文件了。而且协议之间是可以区分的,不然的话,连续传输多个文件,如果无法区分属于每个文件的字节流,那么传输是毫无意义的。

定义数据的发送格式(协议)

这里的发送格式(我感觉和计算机网络中的协议有点像,也就称它为一个简单的协议吧)。
发送格式:数据头+数据体

数据头:一个长度为一字节的数据,表示的内容是文件的类型。
注:因为每个文件的类型是不一样的,而且长度也不相同,我们知道协议的头部一般是具有一个固定长度的(对于可变长的那些我们不考虑),所以我采用一个映射关系,即一个字节数字表示一个文件的类型。

举一个例子,如下:

keyvalue
0txt
1png
2jpg
3jpeg
4avi

注:这里我做的是一个模拟,所以我只要测试几种就行了。

数据体: 文件的数据部分(二进制数据)。



Talk is cheap, show me your code.

我们来看代码吧!

客户端

协议头部类
package com.dragon;

public class Header {
	private byte type;      //文件类型
	private long length;      //文件长度
	
	public Header(byte type, long length) {
		super();
		this.type = type;
		this.length = length;
	}

	public byte getType() {
		return this.type;
	}
	
	public long getLength() {
		return this.length;
	}
}



发送文件类
package com.dragon;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.Socket;

/**
 * 模拟文件传输协议:
 * 协议包含一个头部和一个数据部分。
 * 头部为 9 字节,其余为数据部分。
 * 规定头部包含:文件的类型、文件数据的总长度信息。
 * */
public class FileTransfer {
	private byte[] header = new byte[9];   //协议的头部为9字节,第一个字节为文件类型,后面8个字节为文件的字节长度。
	
	/**
	 *@param src source folder 
	 * @throws IOException 
	 * @throws FileNotFoundException 
	 * */
	public void transfer(Socket client, String src) throws FileNotFoundException, IOException {
		File srcFile = new File(src);
		File[] files = srcFile.listFiles(f->f.isFile());
		//获取输出流
		BufferedOutputStream bos = new BufferedOutputStream(client.getOutputStream());
		for (File file : files) {
			try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(file))){
				 //将文件写入流中
				String filename = file.getName();
				System.out.println(filename);
				//获取文件的扩展名
				String type = filename.substring(filename.lastIndexOf(".")+1);
				long len = file.length();
				//使用一个对象来保存文件的类型和长度信息,操作方便。
				Header h = new Header(this.getType(type), len);
				header = this.getHeader(h);
				
				//将文件基本信息作为头部写入流中
				bos.write(header, 0, header.length);
				//将文件数据作为数据部分写入流中
				int hasRead = 0;
				byte[] b = new byte[1024];
				while ((hasRead = bis.read(b)) != -1) {
					bos.write(b, 0, hasRead);
				}
				bos.flush();   //强制刷新,否则会出错!
			}
		}
	}
	
	private byte[] getHeader(Header h) {
		byte[] header = new byte[9];
		byte t = h.getType();  
		long v = h.getLength();
		header[0] = t;                  //版本号
		header[1] = (byte)(v >>> 56);   //长度
		header[2] = (byte)(v >>> 48);
		header[3] = (byte)(v >>> 40);
		header[4] = (byte)(v >>> 32);
		header[5] = (byte)(v >>> 24);
		header[6] = (byte)(v >>> 16);
		header[7] = (byte)(v >>>  8);
		header[8] = (byte)(v >>>  0);
		return header;
	}
	
	/**
	 * 使用 0-127 作为类型的代号
	 * */
	private byte getType(String type) {
		byte t = 0;
		switch (type.toLowerCase()) {
		case "txt": t = 0; break;
		case "png": t=1; break;
		case "jpg": t=2; break;
		case "jpeg": t=3; break;
		case "avi": t=4; break;
		}
		return t;
	}
}

注:

  1. 发送完一个文件后需要强制刷新一下。因为我是使用的缓冲流,我们知道为了提高发送的效率,并不是一有数据就发送,而是等待缓冲区满了以后再发送,因为 IO 过程是很慢的(相较于 CPU),所以如果不刷新的话,当数据量特别小的文件时,可能会导致服务器端接收不到数据(这个问题,感兴趣的可以去了解一下。),这是一个需要注意的问题。(我测试的例子有一个文本文件只有31字节)。

  1. getLong() 方法将一个 long 型数据转为 byte 型数据,我们知道 long 占8个字节,但是这个方法是我从Java源码里面抄过来的,有一个类叫做 DataOutputStream,它有一个方法是 writeLong(),它的底层实现就是将 long 转为 byte,所以我直接借鉴过来了。(其实,这个也不是很复杂,它只是涉及了位运算,但是写出来这个代码就是很厉害了,所以我选择直接使用这段代码,如果对于位运算感兴趣,可以参考一个我的博客:位运算)。


测试类
package com.dragon;

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

//类型使用代号:固定长度
//文件长度:long->byte 固定长度
public class Test {
	public static void main(String[] args) throws UnknownHostException, IOException {
		FileTransfer fileTransfer = new FileTransfer();
		try (Socket client = new Socket("127.0.0.1", 8000)) {
			fileTransfer.transfer(client, "D:/DBC/src");
		}
	}
}



服务器端

协议解析类
package com.dragon;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.util.UUID;

/**
 * 接受客户端传过来的文件数据,并将其还原为文件。
 * */
public class FileResolve {
	private byte[] header = new byte[9];  
	
	/**
	 * @param des 输出文件的目录
	 * */
	public void fileResolve(Socket client, String des) throws IOException {
		BufferedInputStream bis = new BufferedInputStream(client.getInputStream());
		File desFile = new File(des);
		if (!desFile.exists()) {
			if (!desFile.mkdirs()) {
				throw new FileNotFoundException("无法创建输出路径");
			}
		}
		
		while (true) {	
			//先读取文件的头部信息
			int exit = bis.read(header, 0, header.length);
			
			//当最后一个文件发送完,客户端会停止,服务器端读取完数据后,就应该关闭了,
			//否则就会造成死循环,并且会批量产生最后一个文件,但是没有任何数据。
			if (exit == -1) {
				System.out.println("文件上传结束!");
				break;   
			}
			
			String type = this.getType(header[0]);
			String filename  = UUID.randomUUID().toString()+"."+type;
			System.out.println(filename);
			//获取文件的长度
			long len = this.getLength(header);
			long count = 0L;
			try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(new File(des, filename)))){
				int hasRead = 0;
				byte[] b = new byte[1024];
				while (count < len && (hasRead = bis.read(b)) != -1) {
					bos.write(b, 0, hasRead);
					count += (long)hasRead;
					/**
					 * 当文件最后一部分不足1024时,直接读取此部分,然后结束。
					 * 文件已经读取完成了。
					 * */
					int last = (int)(len-count);
					if (last < 1024 && last > 0) {
						//这里不考虑网络原因造成的无法读取准确的字节数,暂且认为网络是正常的。
						byte[] lastData = new byte[last];
						bis.read(lastData);
						bos.write(lastData, 0, last);
						count += (long)last;
					}
				}
			}
		}
	}
	
	/**
	 * 使用 0-127 作为类型的代号
	 * */
	private String getType(int type) {
		String t = "";
		switch (type) {
		case 0: t = "txt"; break;
		case 1: t = "png"; break;
		case 2: t = "jpg"; break;
		case 3: t = "jpeg"; break;
		case 4: t = "avi"; break;
		}
		return t;
	}
	
	private long getLength(byte[] h) {
		return (((long)h[1] << 56) +
                ((long)(h[2] & 255) << 48) +
                ((long)(h[3] & 255) << 40) +
                ((long)(h[4] & 255) << 32) +
                ((long)(h[5] & 255) << 24) +
                ((h[6] & 255) << 16) +
                ((h[7] & 255) <<  8) +
                ((h[8] & 255) <<  0));
	}
}

注:

  1. 这个将 byte 转为 long 的方法,相信大家也能猜出来了。DataInputStream 有一个方法叫 readLong(),所以我直接拿来使用了。(我觉得这两段代码写的非常好,不过我就看了几个类的源码,哈哈!)

  2. 这里我使用一个死循环进行文件的读取,但是我在测试的时候,发现了一个问题很难解决:什么时候结束循环。 我一开始使用 client 关闭作为退出条件,但是发现无法起作用。后来发现,对于网络流来说,如果读取到 -1 说明对面的输入流已经关闭了,因此使用这个作为退出循环的标志。如果删去了这句代码,程序会无法自动终止,并且会一直产生最后一个读取的文件,但是由于无法读取到数据,所以文件都是 0 字节的文件。 (这个东西产生文件的速度很快,大概几秒钟就会产生几千个文件,如果感兴趣,可以尝试一下,但是最好快速终止程序的运行,哈哈!

if (exit == -1) {
	System.out.println("文件上传结束!");
	break;   
}


测试类

这里只测试一个连接就行了,这只是一个说明的例子。

package com.dragon;

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

public class Test {
	public static void main(String[] args) throws IOException {
		try (ServerSocket server = new ServerSocket(8000)){
			Socket client = server.accept();
			FileResolve fileResolve = new FileResolve();
			fileResolve.fileResolve(client, "D:/DBC/des");
		}	
	}
}


测试结果

Client
在这里插入图片描述
Server
在这里插入图片描述

源文件目录
这里面包含了我测试的五种文件。注意对比文件的大小信息,对于IO的测试,我喜欢使用图片和视频测试,因为它们是很特殊的文件,如果错了一点(字节少了、多了),文件基本上就损坏了,表现为图片不正常显示,视频无法正常播放。
在这里插入图片描述

目的文件目录
在这里插入图片描述

总结

这个问题应该是解决了,我这里经过测试,应该是没有问题的了。我的代码写的不是太好,有时候都没有怎么思考,想到哪就写到哪,这样看来还是有很大问题。这个例子的代码很简单,不过我发现了一个很有趣的问题,因为我最近看到了一个手写 Http 服务器的(使用Java简单的写一个。),自己也尝试了一下(还没看完)。
我们知道 HTTP 协议,也是具有响应头和响应体,我觉得我这个和 HTTP 协议有点相似,虽然我的想法很简陋,但是好像确实是有点相似,可能我看到的东西,对我也有了影响。

从基本思考结束,开始动手写代码,然后是调试、测试,接着就是写博客了,大概也用了几个小时。我挺喜欢这样的,一次性完成代码、博客,当然了这也与这个问题本身对于我来说是很合适有关。经常接触一些知识看来是很有用的,如果我没有学过计算机网络的话,我是不会有这种概念的,那么对于这个问题我就束手无策了,哈哈!

  • 7
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值