Java速通 Day6 – 文件I/O

/ 0评 / 0

终于到正式看看别人的轮子怎么用的时候了,之前都是为了测试引用很少的别人的轮子,但是其实Java提供非常多的轮子,文件I/O这么一个内容里,他都做了非常多的内容,如果说C语言操作就是纯纯的底层逻辑,只有按着字节来读,还能支持ioctl,对于Java就是一个超大封装,字符和字节居然都分开了.

我们如果要找一个具体轮子的使用方法时候,可以打开官方文档直接找.因为别人再理解一遍给你效果可能是大有不同.

https://docs.oracle.com/en/java/javase/22/docs/api/index.html

java.io.File是最古老的文件处理类,他可以检查文件/目录是否存在,是否可读可写可执行,是否隐藏文件,判断指定的Path是路径还是文件还是目录,可以删除空目录,创建目录,简单演示.

File file = new File("./file.txt"); // 创建一个指定路径的文件对象
try {
    file.createNewFile(); // 创建新文件
} catch (IOException e) { // 如果文件路径中的目录不存在,就会扔出异常.
    e.printStackTrace();
}
// 删除文件,也可删除空目录,但不可删除非空目录。在删除非空目录时会返回false
boolean deleteResult = file.delete();
System.out.println("该文件的删除结果为"+(deleteResult?"成功":"失败"));
// 只创建最后一级目录,如果上级目录不存在就返回false
boolean mkdirResult = file.mkdir();
System.out.println("该目录的创建结果为"+(mkdirResult?"成功":"失败"));
// 创建文件路径中所有不存在的目录
boolean mkdirsResult = file.mkdirs();
// 文件重命名,把源文件的名称改为目标名称
boolean renameResult = file.renameTo(dest);

如果涉及具体文件读写,需要用到java.io.FileReader或java.io.FileWriter才行.这里也有新知识点,我们平时读写文件,不确保总是能成功,所以一旦不成功就要捕获异常,并且文件最终还要记得关闭释放内存.

String str = "Hello World";
File file = new File("./file.txt"); // 创建一个指定路径的文件对象
FileWriter writer = null;
try {
	writer = new FileWriter(file); // 创建一个文件写入器
	writer.write(str); // 往文件写入字符串
} catch (IOException e) { // 捕捉到输入输出异常
	e.printStackTrace();
} finally { // 无论是否遇到异常都要释放文件资源
	if (writer != null) {
		try {
			writer.close(); // 关闭文件
		} catch (IOException e) { // 捕捉到输入输出异常
			e.printStackTrace();
		}
	}
}

明显很冗长,所以这种能自动回收资源(AutoCloseable接口)的,可以使用try(...)catch(...)块来简化代码.

String str = "Hello World";
File file = new File("./file.txt"); // 创建一个指定路径的文件对象
try (FileWriter writer = new FileWriter(file)) {
	writer.write(str); // 往文件写入字符串
} catch (IOException e) { // 捕捉到输入输出异常
	e.printStackTrace();
}

读取稍微复杂一些,读取后使用String对象获取具体内容.

File file = new File("./file.txt"); // 创建一个指定路径的文件对象
if (!file.exists() || !file.isFile()) {
	System.out.println("该文件不存在或者它不是个文件");
	return;
}
try (FileReader reader = new FileReader(file)) {
	char[] temp = new char[(int) file.length()]; // 创建与文件大小等长的字符数组
	reader.read(temp); // 从文件读取数据到字节数组
	String content = new String(temp); // 把字符数组转为字符串
	System.out.println(content);
} catch (IOException e) { // 捕捉到输入输出异常
	e.printStackTrace();
}

对于FileReader和FileWriter是一旦执行,立即写到磁盘,这样的缺点也很明显,IO压力大啊!要是偶尔写用一用还行,哪些不断产生的Log之类就不太合适了,所以我们有时候要用BufferedReader和BufferedWriter套娃,为什么说是套娃呢,因为他依然需要依赖FileReader和FileWriter.

下面这个是利用BufferedReader/BufferedWriter复制文件,如果使用的是普通的FileReader和FileWriter,那么读写IO可就爆炸了.

File src = new File("./src.txt"); // 创建一个指定路径的源文件对象
File dest = new File("./dst.txt"); // 创建一个指定路径的目标文件对象
if (!src.exists() || !src.isFile()) {
	System.out.println("源文件不存在,或者它不是个文件");
	return;
}
try (BufferedReader breader = new BufferedReader(new FileReader(src));
		BufferedWriter bwriter = new BufferedWriter(new FileWriter(dest));) {
	while (true) { // 开始遍历文件中的所有字符
		int temp = breader.read(); // 从源文件中读出一个字符
		if (temp == -1) { // read方法返回-1表示已经读到了文件末尾
			break;
		}
		bwriter.write(temp); // 往目标文件写入一个字符
	}
} catch (Exception e) {
	e.printStackTrace();
}

但是前面的读写居然都只能从开头开始,如果是每次都从开头写入,就每次都覆盖文件,如果每次从开头读取,那么就要浪费很多时间查找自己需要的内容.所以Java也提供了java.io.RandomAccessFile这个类,他的重要方法就是seek到指定位置进行read/write,实现真随机位置读写,不过这里都是针对byte[],下面演示随机读取文件.

try (RandomAccessFile raf = new RandomAccessFile("./file.txt", "r")) {
	int length = (int) raf.length(); // 获取随机文件的长度(文件大小)
	byte[] bytes = new byte[length]; // 分配长度为文件大小的字节数组
	raf.read(bytes); // 把随机文件的文件内容读取到字节数组
	String content = new String(bytes); // 把字节数组转成字符串
	System.out.println(content);
} catch (Exception e) {
	e.printStackTrace();
}

除了上面的文件读写处理之外,Java还支持输入输出流形式的文件处理,他归属于java.io.FileInputStream(读取)和java.io.FileOutputStream(写入),他们处理的都是字节.另外由于都支持skip方法,所以没有对应的RandomInputStream或RandomOutputStream.

// 根据指定路径构建文件输入流对象
try (FileInputStream fis = new FileInputStream("./file.txt")) {
	byte[] bytes = new byte[fis.available()];
	//fis.skip(3); // 字节流的skip方法跳过的是字节数
	fis.read(bytes); // 从文件输入流中读取字节数组
	String content = new String(bytes); // 把字节数组转换为字符串
	System.out.println("content="+content);
} catch (Exception e) {
	e.printStackTrace();
}

同理他也有他的BufferedInputStream和BufferedOutputStream,同样是以复制文件来演示.

// 分别构建缓存输入流对象和缓存输出流对象
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("./src.txt"));
		BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("./dst.txt"))) {
	// 分配长度为文件大小的字节数组,available方法返回当前位置后面的剩余部分大小
	byte[] bytes = new byte[bis.available()];
	bis.read(bytes); // 从缓存输入流中读取字节数组
	bos.write(bytes); // 把字节数组写入缓存输出流
	//bos.flush(); // 立即写入磁盘.如果不立即写入,最后调用close方法时也会写入
} catch (Exception e) {
	e.printStackTrace();
}

由于他处理的不是字符,而是具体的字节,所以他也能支持更底层的操作,比如文件压缩解压,不管你原来是什么,压缩后肯定都是二进制字节嘛,不可能都是什么可见字符,而且Java也给我们准备好了GZIPInputStream和GZIPOutputStream.他就是使用Gzip算法进行流压缩/流解压的工具.压缩后的数据是byte[],可以直接用FileOutputStream的write方法直接写入,压缩前数据使用FileInputStream读取到的也是byte[],直接送给GZIPInputStream解压.

比较复杂,主要是套娃有点多,参考代码如下.

public class GzipDemoStream {
	private static String mFileName = "./file.txt";
	
	public static void main(String[] arg) {
		writeZipFile(); // 往文件写入压缩后的数据
		readZipFile(); // 从压缩文件中读取解压后的数据
	}
	
	// 往文件写入压缩后的数据
	private static void writeZipFile() {
		String str = "Hello World,Hello World,Hello World!";
		// 根据指定文件路径构建文件输出流对象
		try (FileOutputStream fos = new FileOutputStream(mFileName)) {
			byte[] zip_bytes = compress(str); // 从字符串获得压缩后的字节数组
			fos.write(zip_bytes); // 把字节数组写入文件输出流
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
	
	// 从压缩文件中读取解压后的数据
	private static void readZipFile() {
		// 根据指定文件路径构建文件输入流对象
		try (FileInputStream fis = new FileInputStream(mFileName)) {
			// 分配长度为文件大小的字节数组。available方法返回当前未读取的大小
			byte[] bytes = new byte[fis.available()];
			fis.read(bytes); // 从文件输入流中读取字节数组
			String content = uncompress(bytes); // 从压缩字节数组获得解压后的字符串
			System.out.println("content="+content);
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
	
	// 从字符串获得压缩后的字节数组
	private static byte[] compress(String str) {
		if (str==null || str.length()<=0) {
			return null;
		}
		byte[] zip_bytes = null; // 声明压缩数据的字节数组
		// 先构建字节数组输出流,再据此构建压缩输出流
		try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
				GZIPOutputStream gos = new GZIPOutputStream(baos);) {
			gos.write(str.getBytes()); // 往压缩输出流写入字节数组
			gos.finish(); // 结束写入操作
			zip_bytes = baos.toByteArray(); // 从字节数组输出流中获取字节数组信息
		} catch (Exception e) {
			e.printStackTrace();
		}
		return zip_bytes;
	}

	// 从压缩字节数组获得解压后的字符串
	private static String uncompress(byte[] bytes) {
		if (bytes==null || bytes.length<=0) {
			return null;
		}
		byte[] unzip_bytes = null; // 声明解压数据的字节数组
		// 分别构建字节数组输出流,以及字节数组输入流,并根据字节数组输入流构建压缩输入流
		try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
				ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
				GZIPInputStream gis = new GZIPInputStream(bais);) {
			byte[] buffer = new byte[1024];
			while (true) {
				// 从压缩输入流中读取数据到字节数组,并返回读到的数据长度
				int length = gis.read(buffer);
				if (length < 0) { // 未读到数据,表示已经读完了
					break;
				}
				baos.write(buffer); // 往字节数组输出流写入字节数组
			}
			unzip_bytes = baos.toByteArray(); // 从字节数组输出流中获取字节数组信息
		} catch (Exception e) {
			e.printStackTrace();
		}
		return new String(unzip_bytes); // 把字节数组转换为字符串,并返回该字符串
	}
}

因为FileOutputStream/FileInputStream处理的是byte[],同样的他也可以把对象保存到文件,这样下次就可以直接从文件还原对象,以前我在C语言中要保存某个结构,就需要获取他的地址开始和数据结构长度,强转为char*类型然后调用fwrite写入到文件,Java中搞了一个专门的接口处理这个事情,这个接口没有需要实现的方法,只需要在类中强调implements Serializable就可以.

public class Fruit implements Serializable {
	private static final long serialVersionUID = 1L; // 该类的实例在序列化时的版本编码

	// 其他内容省略
}

如果ObjectOutputStream/ObjectInputStream配合前面的Gzip使用,可以进一步减少输出的内容大小,而且用户打开看到也是看起来一串乱码.

// 利用对象输出流把序列化对象写入文件
private static void writeObject() {
	Fruit fruit = new Fruit(); // 创建可序列化的用户信息对象
	// 进行一些类的操作,这里省略...
	try (FileOutputStream fos = new FileOutputStream(mFileName);
		 GZIPOutputStream gos = new GZIPOutputStream(fos);
		 ObjectOutputStream oos = new ObjectOutputStream(gos);) {
		oos.writeObject(fruit); // 把对象信息写入文件
		System.out.println("对象序列化成功");
	} catch (Exception e) {
		e.printStackTrace();
	}
}

// 利用对象输入流从文件中读取序列化对象
private static void readObject() {
	Fruit fruit = new Fruit(); // 创建可序列化的用户信息对象
	try (FileInputStream fos = new FileInputStream(mFileName);
		 GZIPInputStream gis = new GZIPInputStream(fos);
			ObjectInputStream ois = new ObjectInputStream(gis);) {
		user = (Fruit) ois.readObject(); // 从文件读取对象信息
		System.out.println("对象反序列化成功");
	} catch (Exception e) {
		e.printStackTrace();
	}
	// 类已经还原,继续操作...
}

另外,类里保存的信息其实是明文的,Gzip也不是加密压缩,除非再配合createCipherOutputStream/createCipherInputStream,那么我们如何不保存类里特定东西呢,只需要给添加上transient修饰就可以了.

private transient String password; // 因为是密码,不想序列化保存出去.

还有类里虽然没有实现什么方法,但是却有serialVersionUID这个常量,目的就是标记这个类的版本,不同版本是不能还原的.如果不加这个,那么就是根据内部算法生成,这样一旦类里修改一丁点,甚至换个平台,这个ID都会改变.

不过前面说了那么多,说的都是传统IO模型,实际上现在推荐用的是NIO模型,为什么要先说传统模型我觉得是因为一下子讲NIO会有点懵吧.因为传统IO的效率一直都比较低,哪怕是增加了缓冲也不尽人意,因为传统的方法用的是阻塞IO,而NIO就是非阻塞模型,其实NIO也不是只能用于文件,他主要是网络,网络非阻塞很重要啊,你要发送数据包,总要等到对方回复再发,这样效率很低的,当然,文件操作一直是阻塞的,毕竟你准备写入,别人又不能再说我也要写.

那么为什么说NIO就是效率高,首先NIO直接通过字节缓存操作文件,而传统IO操作的是应用内存,然后再到系统内存,再到文件,不但内存是两份的消耗,性能也因为多次拷贝而更低,NIO的这个字节缓存ByteBuffer会建立一个通道,一个打通磁盘到内存之间的双向通道,而之前的传统IO,读写是分开的FileInputStream和FileOutputStream,现在都合并为FileChannel.

我们就演示一些常用的接口,当然FileChannel也实现了AutoCloseable,所以也可以用try(...)catch(...)写法,这里只是快速演示一些方法,

FileChannel channel = new RandomAccessFile("./file.txt","rw").getChannel();
channel.isOpen(); // 判断文件通道是否打开
channel.size(); // 获取文件通道的大小(即文件长度)
channel.truncate(10); // 截断文件大小到指定长度
channel.write(ByteBuffer.wrap(str.getBytes())); // 写入特定内容
channel.force(true); // 强制写入磁盘,相当于C语言的flush.
channel.close(); // 关闭文件通道

ByteBuffer是一种特殊空间,他也是FileChannel唯一可用的储存形式,他是有一定的容量,可以修改其容量大小,比如前面的把字符串写入文件,顺序是字符串String->字节数组byte[]->字节缓存->文件,读取过程就是反过来.ByteBuffer也有很多字节的方法,具体查看文档.

下面也给出通过FileChannel直接和间接复制文件.

// 使用文件通道和字节缓存复制文件
private static void copyChannelBuffer() {
	// 分别创建源文件的文件通道,以及目标文件的文件通道
	try (FileChannel src = new FileInputStream("./src.txt").getChannel();
			FileChannel dest = new FileOutputStream("dst.txt").getChannel()) {
		int size = (int) src.size(); // 获取源文件的大小
		ByteBuffer buffer = ByteBuffer.allocateDirect(size); // 分配指定大小的字节缓存
		src.read(buffer); // 把源文件中的数据读到字节缓存
		buffer.flip(); // 必须先调用flip方法,切换到读模式.
		dest.write(buffer); // 把字节缓存中的数据写入目标文件
	} catch (Exception e) {
		e.printStackTrace();
	}
}

// 使用文件通道直接复制文件
private static void copyChannelDirect() {
	// 分别创建源文件的文件通道,以及目标文件的文件通道
	try (FileChannel src = new FileInputStream("./src.txt").getChannel();
			FileChannel dest = new FileOutputStream("./dst.txt").getChannel();) {
		// 下面的transferTo和transferFrom都可以完成文件复制功能,选择其中一个即可
		src.transferTo(0, src.size(), dest); // 操作源文件通道,把数据传给目标文件通道
		//dest.transferFrom(src, 0, src.size()); // 操作目标文件通道,从源文件通道传入数据
	} catch (Exception e) {
		e.printStackTrace();
	}
}

在这一天的学习开始,我们用到了File工具,NIO引入了Paths和Files,并且支持流式处理, 但是个人感觉依然是把东西分的很乱,Files也能判断路径是否为目录,增加而了一些功能比如walk遍历子目录,copy复制文件,前面的FileChannel也可以用Paths.get(文件名)获取Path对象(假设是path)后通过FileChannel.open(path, StandardOpenOption.READ)获取,总体来说这个类看看文档也就知道大概有什么了.

除了字节缓存外,还可以通过FileChannel.MapMode.*获取到MappedByteBuffer在处理大文件时效率会更高一些.,它会将文件的一部分或全部直接灌入系统内存再操作.

到现在基本能处理我所需要做的东西了,后续也是各种轮子了.所以可能会间隔一段时间再发笔记了.

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注