JAVA 对文件内容的快速操作
当我们需要对后台的数据进行持久化时, 通常会选择使用 mysql, oracle, redis 或者 db2 之类作为数据数据存储的地方. 但是由于在下只有一台服务器, 并且运行内存也不是足够的充裕. 整个程序的复杂性也不高, 所以就直接使用文件作为数据持久化存储的容器.
数据样例:
50|江西省新余市|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
51|江西省新余市渝水区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
52|江西省新余市分宜县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
53|江西省新余市仙女湖风景名胜区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
54|江西省新余市高新技术产业开发区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
55|江西省宜春市|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
56|江西省宜春市袁州区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
57|江西省宜春市奉新县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
58|江西省宜春市万载县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
59|江西省宜春市上高县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
60|江西省宜春市宜丰县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
61|江西省宜春市靖安县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
62|江西省宜春市铜鼓县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
63|江西省宜春市丰城市|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
64|江西省宜春市樟树市|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
65|江西省宜春市高安市|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
66|江西省宜春市宜经济技术开发区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
67|江西省萍乡市|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
68|江西省萍乡市安源区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
69|江西省萍乡市湘东区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
70|江西省萍乡市莲花县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
71|江西省萍乡市上栗县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
72|江西省萍乡市芦溪县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
73|江西省萍乡市经济技术开发区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
74|江西省赣州市|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
75|江西省赣州市章贡区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
76|江西省赣州市南康区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
77|江西省赣州市赣县区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
78|江西省赣州市信丰县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
79|江西省赣州市大余县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
80|江西省赣州市上犹县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
81|江西省赣州市崇义县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
82|江西省赣州市安远县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
83|江西省赣州市龙南经济技术开发区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
84|江西省赣州市龙南县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
85|江西省赣州市定南县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
86|江西省赣州市全南县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
87|江西省赣州市宁都县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
89|江西省赣州市于都县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
90|江西省赣州市兴国县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
91|江西省赣州市会昌县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
92|江西省赣州市寻乌县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
93|江西省赣州市石城县|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
94|江西省赣州市瑞金市|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
95|江西省赣州市赣州经济技术开发区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
96|江西省赣州市赣州市蓉江新区|127.0.0.1|6665|username|******|/home/username/tmp|utf8|0|1
数据以 | 作为分割符号. 当查询的时候还是比较简单的. 但是一旦遇到修改和删除操作的时候就有点难受了.
方法1: 使用 BufferedReader 和 BufferedWriter 两个流进行操作.
package com.codetool.common;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.util.StringJoiner;
import java.util.function.Function;
/**
* 操作文件工具类
*/
public class FileHelper {
/**
* 包分割符
*/
public static final String PACKAGE_SEPARATOR = ".";
/**
* 读取文件, 并处理每行的结果. 如果处理函数返回 false, 则不将结果加入进去
*
* @param file 文件
* @param function 处理函数
*/
public static String readLine(File file, Function<String, Boolean> function) {
try (FileInputStream stream = new FileInputStream(file)) {
return readLine(stream, function);
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
/**
* 读取文件, 并处理每行的结果. 如果处理函数返回 false, 则不将结果加入进去
*
* @param is 文件流
* @param function 处理函数
*/
public static String readLine(InputStream is, Function<String, Boolean> function) {
StringJoiner joiner = new StringJoiner("");
try (BufferedReader br = new BufferedReader(new InputStreamReader(is))) {
String line = null;
while ((line = br.readLine()) != null) {
if (function != null && function.apply(line)) {
joiner.add(line);
}
}
} catch (IOException e) {
e.printStackTrace();
}
return joiner.toString();
}
/**
* 推荐使用, 基于 NIO 的方式读取文件内容. 如果处理函数返回 false, 则不将结果加入进去
*
* @param file 读取的文件
* @param encoding 字符集
* @param function 每行内容执行的函数
*/
public static String readLineByNIO(File file, String encoding, Function<String, Boolean> function) {
if (org.apache.commons.lang3.StringUtils.isBlank(encoding)) {
encoding = StandardCharsets.UTF_8.toString();
}
// 存放读取的每行数据
// List<String> lines = new ArrayList<>();
StringJoiner joiner = new StringJoiner("");
// 一次读取 8kb
ByteBuffer byteBuffer = ByteBuffer.allocate(8 * 1024);
// 由于是按固定字节读取,在一次读取中,第一行和最后一行经常是不完整的行,因此定义此变量来存储上次的最后一行和这次的第一行的内容,
// 并将之连接成完成的一行,否则会出现汉字被拆分成2个字节,并被提前转换成字符串而乱码的问题
byte[] bytes = new byte[0];
// 将文件转换为 Channel
try (FileChannel channel = new FileInputStream(file).getChannel()) {
// 循环读取数据到缓冲区
while (channel.read(byteBuffer) != -1) {
// 读取结束后的位置,相当于读取的长度
int readLength = byteBuffer.position();
// 用来存放读取的内容的数组
byte[] bs = new byte[readLength];
// 读取数据到 bs 数组中
byteBuffer.rewind();
byteBuffer.get(bs);
byteBuffer.clear();
int startNum = 0;
// 换行符, 回车符
int LF = 10, CR = 13;
// 是否有换行符
boolean hasLF = false;
// 解析 bs 这个数组, 判断里面是否包含换行符
for (int i = 0; i < readLength; i++) {
if (bs[i] == LF) {
hasLF = true;
int tempNum = bytes.length;
int lineNum = i - startNum;
// 数组大小已经去掉换行符
byte[] lineByte = new byte[tempNum + lineNum];
// 填充了lineByte[0]~lineByte[tempNum-1]
System.arraycopy(bytes, 0, lineByte, 0, tempNum);
bytes = new byte[0];
// 填充lineByte[tempNum]~lineByte[tempNum+lineNum-1]
System.arraycopy(bs, startNum, lineByte, tempNum, lineNum);
// 一行完整的字符串(过滤了换行和回车)
String line = new String(lineByte, 0, lineByte.length, encoding);
if (function != null && function.apply(line)) {
joiner.add(line);
}
// lines.add(line);
// 过滤回车符和换行符
if (i + 1 < readLength && bs[i + 1] == CR) {
startNum = i + 2;
} else {
startNum = i + 1;
}
}
}
if (hasLF) {
bytes = new byte[bs.length - startNum];
System.arraycopy(bs, startNum, bytes, 0, bytes.length);
} else {
// 兼容单次读取的内容不足一行的情况
byte[] toTemp = new byte[bytes.length + bs.length];
System.arraycopy(bytes, 0, toTemp, 0, bytes.length);
System.arraycopy(bs, 0, toTemp, bytes.length, bs.length);
bytes = toTemp;
}
}
// 兼容文件最后一行没有换行的情况
if (bytes.length > 0) {
String line = new String(bytes, 0, bytes.length, encoding);
// lines.add(line);
if (function != null && function.apply(line)) {
joiner.add(line);
}
}
} catch (Exception e) {
e.printStackTrace();
}
return joiner.toString();
}
// 对 ByteBuffer 进行扩容操作
private static ByteBuffer reAllocate(ByteBuffer buf) {
final int capacity = buf.capacity();
byte[] newBuffer = new byte[capacity * 2];
System.arraycopy(buf.array(), 0, newBuffer, 0, capacity);
return (ByteBuffer) ByteBuffer.wrap(newBuffer).position(capacity);
}
/**
* 将文件内容以覆盖的形式写入到目标文件中
*
* @param content 文件内容
* @param dest 目标文件
*/
public static boolean persistence(String content, File dest) {
File dir = new File(dest.getParent());
try {
dir.mkdirs();
dest.createNewFile();
FileChannel destChannel = new FileOutputStream(dest).getChannel();
destChannel.write(ByteBuffer.wrap(content.getBytes()));
destChannel.close();
return true;
} catch (IOException e) {
return false;
}
}
/**
* 将文件内容以追加的形式写入到目标文件中
*
* @param content 文件内容
* @param dest 目标文件
*/
public static boolean append(String content, File dest) {
if (!dest.exists()) {
return persistence(content, dest);
}
try (FileChannel channel = new FileOutputStream(dest).getChannel()) {
byte[] bytes = content.getBytes();
ByteBuffer buf = ByteBuffer.wrap(bytes);
channel.write(buf);
return true;
} catch (IOException e) {
return false;
}
}
}
当需要删除数据时, 可以使用 readLineByNIO 来进行数据过滤得到最终想要的数据, 然后以覆盖的形式将新数据写入到原来的文件中, 写法如下:
public static void main(String[] args) {
File file = new File("数据文件绝对路径");
String res = FileHelper.readLineByNIO(file, StandardCharsets.UTF_8.toString(), line -> {
// 删除掉 id = 76 的数据
String id = line.split("|")[0];
if ("76" == id) {
// return false 表示这行数据不要了
return false;
}
return true;
});
FileHelper.persistence(res, file);
}
当我们需要修改某一行的数据的时候, 写法如下:
public static void main(String[] args) {
File file = new File("数据文件绝对路径");
StringBuilder sb = new StringBuilder();
FileHelper.readLineByNIO(file, StandardCharsets.UTF_8.toString(), line -> {
// 修改掉 id = 76 的数据
String id = line.split("|")[0];
if ("76" == id) {
sb.append("修改后的那一行的内容");
} else {
sb.append(line);
}
return false;
});
FileHelper.persistence(res, file);
}
上面的方法始终是针对同一个文件做了两次的流打开关闭. 后面想到了RandomAccessFile.java, 它可以只针对文件做一次流的打开和关闭操作就实现相同的功能. 于是开始敲代码
package com.codetool.common;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.util.StringJoiner;
import java.util.function.Function;
/**
* 操作文件工具类
*/
public class FileHelper {
/**
* 将文本内容吸入到目标文件的指定行中, 基于 RandomAccessFile 中的文件指针来实现的.
* 无需重新创建新的文件流
*
* @param content 文本内容
* @param line 写入行
* @param dest 写入文件
* @return
*/
public static boolean write(String content, long line, File dest) {
try (RandomAccessFile file = new RandomAccessFile(dest, "rw")) {
if (line < 0) {
return false;
}
int cLine = 1;
byte[] b = content.getBytes();
while (cLine++ < line) {
// 已经不存在行内容了, 这里我们就不进行处理了..
if (file.readLine() == null) {
file.write(b);
return false;
}
}
long position = file.getFilePointer() - 1;
long oldLen = file.length();
long newLen = file.length() + b.length;
file.setLength(newLen);
// 把后面的内容往后面挪, 加个缓冲区, 提升读写速度
int size = 1024;
byte[] bytes = new byte[size];
int len; long readStart = Math.max(oldLen - size, position), writeStart = Math.max(newLen - size, position + b.length);
while (true) {
// 跳转到读取指针位置
file.seek(readStart);
// 如果 readStart 已经和 p2 保持一致了, 也就说明就差最后一次没有读完了
len = file.read(bytes);
file.seek(writeStart);
file.write(bytes, 0, bytes.length);
// 如果读完了, 那么我们就退出循环
if (len == -1 || position == readStart) {
break;
}
if (readStart - position <= size) {
bytes = new byte[(int)(readStart - position)];
}
readStart = Math.max(readStart - size, position);
writeStart = Math.max(writeStart - size, position + b.length);
}
file.seek(position);
file.write(b);
return true;
} catch (Exception e) {
return false;
}
}
/**
* 基于 RandomAccessFile 实现清空指定行
*
* @param line
* @param dest
* @return
*/
public static boolean clear(long line, File dest) {
try (RandomAccessFile file = new RandomAccessFile(dest, "rw")) {
if (line < 0) {
return false;
}
int cLine = 1;
while (cLine++ < line) {
// 已经不存在行内容了, 这里我们就不进行处理了..
if (file.readLine() == null) {
return false;
}
}
// 拿到写入行的开始位置指针
long p1 = file.getFilePointer();
// 拿到写入行的结束位置指针.
file.readLine();
long p2 = file.getFilePointer() - 1;
long oldLen = file.length();
long newLen = oldLen - (p2 - p1);
// 进行缩容操作, 把后面的内容往前移动
int size = 1024;
// 读取到 bytes 中
byte[] bytes = new byte[size];
int len; long readStart = p2, writeStart = p1;
while (true) {
file.seek(readStart);
len = file.read(bytes);
file.seek(writeStart);
file.write(bytes, 0, bytes.length);
if (len == -1) {
break;
}
readStart = readStart + size;
writeStart = writeStart + size;
}
file.setLength(newLen);
return true;
} catch (Exception e) {
return false;
}
}
/**
* 将文本内容写入到目标文件的指定行中. 基于 RandomAccessFile 中的文件指针来实现的.
* 无需重新创建新的文件流
*
* @param content 文本内容
* @param line 写入行
* @param dest 写入文件
* @return
*/
public static boolean replace(String content, long line, File dest) {
try (RandomAccessFile file = new RandomAccessFile(dest, "rw")) {
if (line < 0) {
return false;
}
int cLine = 1;
byte[] b = content.getBytes();
while (cLine++ < line) {
// 已经不存在行内容了, 这里我们就直接在这个行写入内容. 然后直接结束回合
if (file.readLine() == null) {
file.write(b);
return true;
}
}
// 拿到写入行的开始位置指针
long p1 = file.getFilePointer();
// 拿到写入行的结束位置指针.
file.readLine();
long p2 = file.getFilePointer() - 1;
// 写入行的新内容和旧内容的长度偏差值
long padding = (p1 - p2 + b.length); // 350
long oldLen = file.length();
// 现在第 48 个位置的字节需要移动到第 12 个字节的位置. 最后我们在进行一次缩容操作. 如果总字节数变大了. 那么我们需要进行优先的扩容操作
// 循环反复. 知道所有的字节都移动结束
long newLen = file.length() + padding;
int size = 1024;
// 如果是扩容操作
if (padding > 0) {
System.out.println("进入文件空间扩容操作");
file.setLength(newLen);
// 加个缓冲区, 提升读写速度
byte[] bytes = new byte[size];
int len; long readStart = Math.max(oldLen - size, p2), writeStart = Math.max(newLen - size, p2 + padding);
while (true) {
// 跳转到读取指针位置
file.seek(readStart);
// 如果 readStart 已经和 p2 保持一致了, 也就说明就差最后一次没有读完了
len = file.read(bytes);
file.seek(writeStart);
file.write(bytes, 0, bytes.length);
// 如果读完了, 那么我们就退出循环
if (len == -1 || p2 == readStart) {
break;
}
if (readStart - p2 <= size) {
bytes = new byte[(int)(readStart - p2)];
}
readStart = Math.max(readStart - size, p2);
writeStart = Math.max(writeStart - size, p2 + padding);
}
}
// 如果是缩容操作
else if (padding < 0) {
// 读取到 bytes 中
byte[] bytes = new byte[size];
int len; long readStart = p2, writeStart = p1 + b.length;
while (true) {
file.seek(readStart);
len = file.read(bytes);
file.seek(writeStart);
file.write(bytes, 0, bytes.length);
if (len == -1) {
break;
}
readStart = readStart + size;
writeStart = writeStart + size;
}
file.setLength(newLen);
}
// 把新增的内容写进去
file.seek(p1);
file.write(b);
return true;
} catch (Exception e) {
return false;
}
}
}
经过不断的测试和反复的文件读写, 终于实现了这 3 个功能接口. 如果我们需要删除某一行的内容, 那么直接调用 clear(行号, 文件) 就可以完成这个功能了. 如果我要修改某一行的内容, 那么我只需要调用 replace(修改后的内容, 行号, 文件) 就可以实现了. 新增一行内容的话直接调用 write(新增德的内容, 行号, 文件), 真的香
这 3 个功能是基于文件指针实现的.
首先文本文件内容如下:
data.txt
Hello,world.
Spring
Tomcat
我们可以通过 file.getFilePointer() 拿到当前文件的指针. 通过 readLine() 后调用 file.getFilePointer() 就可以拿到每一行开始和结尾的指针
当我要在第 2 行写入一个 tomcat 时, 我只需要将指针跳转到 14 后直接写入即可
这样就可以了, 但是事情从而不会这么简单的. 如果我要在第二行写入一个 nginx 那么会怎么样了. 首先把图片还原成之前的样子
指针驳回到14, 现在开始写 nginx
得到的结果是这样的, 那么如果我们要实现替换行内容的功能的话, 我们就需要把指针为 20 前面的 g 给去掉. 怎么去掉呢, 来个最简单的方法, 对 nginx 进行长度不齐, 将 nginx 和 spring 这两个单词的长度补充成一样的长度, 不足的位置直接补空格.
这样看上去也是可行的
但是, 如果我想第二行插入一个 SpringBoot, 很明显 SpringBoot 的长度 > Spring 的长度. 如果跳到指定位置直接写:
那就会变成这个样子, 哇. 数据完全不可用了呀.
此时, 我们就需要考虑一个容量变化的问题了. 首先我们需要计算出新插入行的内容和原来行的内容的字节差量, 原来这一行的有效内容长度是 6 个字节(不包括\n), 现在要写一个的字节长度是 10 个字节, 此时字节差量为 4 个字节, 那么我们就需要对整个文件进行一个字节扩容, 先补充 4 个字节上去
然后我们需要把之前的最后一个位置的字节内容移动到现在的最后一个位置中去, 循环反复. 整个移动完成后是这样的
file.setLength(32)
while (long start = 32 - 1; start > 21; start--) {
file.seek(start - 4);
file.readByte();
file.seek(start);
file.writeByte();
}
最后再 seek 到 14 的位置, 将 SpringBoot 写进去
那整个行内容写入就完成了. 原理就是位置上的数据进行迁移变化.
当整个文本文件内容过大的时候, 可以考虑分文件存储, 当进行短内容替换长内容时, 可以考虑对端内容进行空格补齐. 尽量不要删除行内容, 可以使用逻辑对行内容进行标记, 使用逻辑当作删除处理