创建 Spring Boot 项目, Spring Boot 2 系列版本, Java 8 , 引入 MyBatis, Lombok 依赖
提示:是正在努力进步的小菜鸟一只,如有大佬发现文章欠佳之处欢迎批评指点~ 废话不多说,直接上干货!
整体目录结构 :
本文主要实现 server 包
一、序列化 / 反序列化
Message 对象(或后续的网络请求需要的数据)需要转成⼆进制写⼊⽂件. 并且也需要把⽂件中的⼆进制读出来解析成对象. 此处针对这⾥的逻辑进⾏封装
- 序列化: 对象 --> 二进制数据
- 反序列化: 二进制数据 --> 对象
此处使用 Java 标准库提供的序列化 / 反序列化工具, 使⽤ ObjectInputStream 类 / ObjectOutputStream 类进⾏序列化 / 反序列化操作. 通过内部的 readObject / writeObject 即可完成对应操作
// 序列化
public static byte[] toByteArray(Object object) throws IOException {
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
try (ObjectOutputStream outputStream = new ObjectOutputStream(byteArrayOutputStream)) {
outputStream.writeObject(object);
}
return byteArrayOutputStream.toByteArray();
}
}
// 反序列化
public static Object toObject(byte[] array) throws IOException {
Object object = null;
try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(array)) {
try (ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) {
object = objectInputStream.readObject();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
return object;
}
-
使⽤ ByteArrayInputStream / ByteArrayOutputStream 针对 byte[] 进⾏封装, 相当于提供了一个变长字节数组, (序列化之前不知道这个对象转成字节数组之后有多长, 直接拿 ByteArrayOutputStream 接收即可 )
-
这两个流对象是纯内存的, 不需要进⾏ close, 此处仍然套上了 try() 也没什么副作用, 养成好习惯~
-
readObject 和 writeObject 这两个方法表示"从哪读", “写到哪”, 取决于和哪个流对象关联, 此处是和 byteArrayOutputStream / byteArrayInputStream, 所以表示"从字节数组里读". “写到字节数组” , 如果和网络( socket 的 输入输出流) 关联, 那就是"从网卡里读", “写到网卡里”
补充说明, 有些序列化 / 反序列场景可以引入"serialVersionUID “, 表示"版本号”, 如下代码所示
public static final long serialVersionUID = 1L;
实际开发中, 代码可能会经常修改和调整, 比如今天定义好了 Message 类, 序列化后写入了文件, 过段时间有给这个类扩充了新功能, 然后再从文件中反序列化出来 message 对象, 大概率是错的, 即使程序不报错, 数据格式也会错乱
为了防止这种情况发生, 修改 Message 类后就把 serialVersionUID 手动更新, 这样再序列化 / 反序列化, 如果版本号不一致, 就会直接报错, 显式的提醒程序员, 而不是使用错乱的数据执行后续代码
二、文件存储设计
1, 队列目录
消息需要在硬盘上存储. 但是并不直接放到数据库中, ⽽是直接使⽤⽂件存储
原因如下:
- 对于消息的操作并不需要复杂的 增删改查
- 对于⽂件的操作效率⽐数据库会⾼很多
主流 MQ 的实现(包括 RabbitMQ), 都是把消息存储在⽂件中, ⽽不是数据库中
我们给每个队列分配⼀个⽬录. ⽬录的名字为 data + 队列名. 形如 ./data/testQueue
该⽬录中包含两个固定名字的⽂件.
- queue_data.txt 消息数据⽂件, ⽤来保存消息内容
- queue_stat.txt 消息统计⽂件, ⽤来保存消息统计信息
服务器中可以有 N 个目录, 也就可以有 N 个 queue 目录
2, 消息数据文件
queue_data.txt ⽂件格式 : 使⽤⼆进制⽅式存储, 每个消息分成两个部分
- 前四个字节, 表⽰ Message 对象的⻓度(字节数)
- 后⾯若⼲字节, 表⽰ Message 内容
消息和消息之间⾸尾相连, 每个 Message 基于 Java 标准库的 ObjectInputStream / ObjectOutputStream 序列化
新增和删除消息时, 就需要知道文件中消息的位置, 上篇文章设计核心类的时候, Message 这个类中就给了两个成员属性表示 “偏移量”, 并且使用 transient 修饰, 不被序列化
// 消息存储在文件中的偏移量(字节, 约定 "[,)" 区间 )
private transient long offsetBegin = 0;
private transient long offsetEnd = 0;
3, 消息统计文件
queue_stat.txt ⽂件格式: 使⽤⽂本⽅式存储
⽂件中只包含⼀⾏, ⾥⾯包含两列(都是整数), 使⽤ \t 分割
第⼀列表⽰当前总的消息数⽬. 第⼆列表⽰有效消息数⽬
形如: 100\t50
三、硬盘管理 – 文件
1, 创建 MessageFileManager 类
充分结合面向对象思想, 创建一个内部类 Stat 表示消息统计文件中, 定义两个成员属性 totalCount, validCount 表示消息总数和有效消息数
写出获取队列目录, 消息文件, 统计文件的路径
public class MessageFileManager {
/**
* 内部类 表示 queue_stat.txt 文件中需要的的字段
*/
public static class Stat {
public int totalCount; // 总消息数
public int validCount; // 有效消息数
}
// 获取队列的存储目录
private String getQueueDir(String queueName) {
return "./data/" + queueName;
}
// 获取该队列的消息存储数据文件
private String getQueueDataPath(String queueName) {
return getQueueDir(queueName) + "/queue_data.txt";
}
// 获取该队列的消息存储统计文件
private String getQueueStatPath(String queueName) {
return getQueueDir(queueName) + "/queue_stat.txt";
}
}
2, createQueueFiles() 创建目录/文件
创建文件之后别忘了在统计文件中初始化 totalCount 和 validCount
public void createQueueFiles(String queueName) throws IOException {
File queueDir = new File(getQueueDir(queueName));
if (!queueDir.exists()) {
boolean result1 = queueDir.mkdirs();
if (!result1) {
throw new IOException("[MessageFileManager.createQueueFiles()] 创建目录失败: " +
queueDir.getAbsolutePath());
}
}
File dataPath = new File(getQueueDataPath(queueName));
if (!dataPath.exists()) {
boolean result2 = dataPath.createNewFile();
if (!result2) {
throw new IOException("[MessageFileManager.createQueueFiles()] 创建data文件失败: " +
dataPath.getAbsolutePath());
}
}
File statPath = new File(getQueueStatPath(queueName));
if (!statPath.exists()) {
boolean result3 = statPath.createNewFile();
if (!result3) {
throw new IOException("[MessageFileManager.createQueueFiles()] 创建stat文件失败: " +
statPath.getAbsolutePath());
}
}
// 给 stat.txt 文件中写入默认数据
Stat stat = new Stat();
stat.totalCount = 0;
stat.validCount = 0;
writeStat(queueName, stat);
}
3, deleteFiles() 删除目录/文件
如果服务器要删除某个队列, 那么这个队列的消息的相关数据文件也要删除
File 类的 delete 方法只能删除空目录, 因此需要先把内部的闻件先删除掉先删除文件再删除目录
public void deleteFiles(String queueName) throws IOException {
File dataPath = new File(getQueueDataPath(queueName));
boolean result1 = dataPath.delete();
File statPath = new File(getQueueStatPath(queueName));
boolean result2 = statPath.delete();
File queueDir = new File(getQueueDir(queueName));
boolean result3 = queueDir.delete();
if (!(result1 && result2 && result3)) {
throw new IOException("[MessageFileManager.deleteFiles()] 删除目录文件失败: " +
queueDir.getAbsolutePath());
}
}
4, checkFileExists() 检查目录/文件是否存在
判定该队列的消息⽂件和统计⽂件是否存在. ⼀旦出现缺失, 则不能进⾏后续⼯作
public boolean checkFileExists(String queueName) {
File queueDir = new File(getQueueDir(queueName));
File dataPath = new File(getQueueDataPath(queueName));
File statPath = new File(getQueueStatPath(queueName));
return queueDir.exists() && dataPath.exists() && statPath.exists();
}
5, readStat() 从 stat.txt 文件中读数据
private Stat readStat(String queueName) {
Stat stat = new Stat();
try (InputStream inputStream = new FileInputStream(getQueueStatPath(queueName))) {
Scanner scanner = new Scanner(inputStream);
stat.totalCount = scanner.nextInt();
stat.validCount = scanner.nextInt();
return stat;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
6, writeStat() 从 stat.txt 文件中写数据
private void writeStat(String queueName, Stat stat) {
// 使用 PrintWrite 来写文件.
try (OutputStream outputStream = new FileOutputStream(getQueueStatPath(queueName))) {
PrintWriter writer = new PrintWriter(outputStream);
writer.write(stat.totalCount + "\t" + stat.validCount);
writer.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
7, sendMessage() 发送消息
自定义一个异常类
public class MQException extends Exception {
public MQException(String message) {
super(message);
}
}
这里的发送消息, 并不是把生产者把消息发送给服务器, 而是这个把消息写入到文件存储起来, 只是作为发送消息这个功能的背后的一环
最后别忘了更新 stat 统计文件中的数据
public void sendMessage(MessageQueue queue, Message message) throws IOException, MQException {
// 1, 检查目录和文件是否存在
boolean result = checkFileExists(queue.getName());
if (!result) {
throw new MQException("[MessageFileManager.sendMessage()] queueName=" + queue.getName() + "的文件不存在");
}
// 2, 消息序列化
byte[] messageBinary = BinaryUtil.toByteArray(message);
synchronized (queue) {
// 3, 设置消息在文件中的偏移量
String dataPath = getQueueDataPath(queue.getName());
File dataFile = new File(dataPath);
message.setOffsetBegin(dataFile.length() + 4);
message.setOffsetEnd(dataFile.length() + 4 + messageBinary.length);
// 4, 写入文件
try (OutputStream outputStream = new FileOutputStream(dataPath, true)) {
try (DataOutputStream dataOutputStream = new DataOutputStream(outputStream)) {
dataOutputStream.writeInt(messageBinary.length); // 消息长度(4字节)
dataOutputStream.write(messageBinary); // 消息本体(messageBinary.length字节)
}
}
// 5, 更新 stat.txt 文件中的数据
Stat stat = readStat(queue.getName());
stat.validCount += 1;
stat.totalCount += 1;
writeStat(queue.getName(), stat);
}
}
- 考虑线程安全, 按照队列维度进⾏加锁
- 使⽤ DataOutputStream 进⾏⼆进制写操作. ⽐原⽣ OutputStream 要⽅便
- 需要记录 Message 对象在⽂件中的偏移量. 后续的删除操作依赖这个偏移量定位到消息. offsetBegin 是原有⽂件⼤⼩的基础上, 再 + 4, 4 个字节是存放消息⼤⼩的空间. (参考下图)
8, deleteMessage() 删除消息
此处的删除只是 “逻辑删除”, 即把 Message 类中的 isValid 字段设置为 0.
public void deleteMessage(MessageQueue queue, Message message) throws IOException {
synchronized (queue) {
// 1, 随机访问文件, 根据偏移量读出消息 (rw 表示打开方式, 可读也可写)
try (RandomAccessFile randomAccessFile = new RandomAccessFile(getQueueDataPath(queue.getName()), "rw")) {
// 2, 读取二进制消息
byte[] messageBinaryFrom = new byte[(int) (message.getOffsetEnd() - message.getOffsetBegin())];
randomAccessFile.seek(message.getOffsetBegin()); // 光标移动
randomAccessFile.read(messageBinaryFrom);
// 3, 二进制消息-->消息对象
Message messageInFile = (Message) BinaryUtil.toObject(messageBinaryFrom);
// 4, 修改isValid字段
messageInFile.setIsValid((byte) 0x0);
// 5, 消息对象-->二进制消息
byte[] messageBinaryTo = BinaryUtil.toByteArray(messageInFile);
// 6, 写入文件
randomAccessFile.seek(message.getOffsetBegin()); // 光标移动
randomAccessFile.write(messageBinaryTo);
}
// 5, 更新 stat.txt 文件中的数据
Stat stat = readStat(queue.getName());
stat.validCount -= 1;
writeStat(queue.getName(), stat);
}
}
- 同样使用 synchronized 针对队列加锁
- 使⽤ RandomAccessFile 来随机访问到⽂件的内容
- 根据 Message 中的 offsetBegin 和 offsetEnd 定位到消息在⽂件中的位置. 通过
randomAccessFile.seek 操作⽂件指针(相当于鼠标光标)偏移过去. 再读取 - 读出的结果解析成 Message 对象, 修改 isValid 字段, 再重新写回⽂件. 注意写的时候要重新设定⽂件指针(光标)的位置. ⽂件指针会随着上述的读操作产⽣改变
- 最后, 要记得更新统计⽂件, 把合法消息 - 1
9, isNeedGC() 是否需要垃圾回收
此处我们自定义一个策略 : 当文件中消息总数超过 2k, 但有效消息不足 30% 触发垃圾回收
public boolean isNeedGC(String queueName){
Stat stat = readStat(queueName);
return stat.totalCount > 2000 && (stat.validCount * 1.0 / stat.totalCount < 0.3);
}
10, gc() 垃圾回收
之前的删除操作, 只是把消息在⽂件上标记成了⽆效. 并没有腾出硬盘空间. 最终⽂件⼤⼩可能会越积越多. 因此需要定期的进⾏批量清除, 此处参考使用 JVM 的复制算法
GC 的时候会把所有有效消息加载出来(后面介绍这个方法)
, 写⼊到⼀个新的消息⽂件中, 使⽤新⽂件, 代替旧⽂件即可
GC 的过程中不允许其他线程对该文件进行修改, 所以对整个方法加锁
public void gc(MessageQueue queue) throws IOException, MQException {
synchronized (queue) {
String queueName = queue.getName();
// 1, 记录开始执行时间
long startGC = System.currentTimeMillis();
// 2, 创建(临时的)new_queue_data.txt 文件
File newDataFile = new File(getNewQueueDataPath(queueName));
if (newDataFile.exists()) {
throw new MQException("[MessageFileManager.gc()] new_queue_data.txt 文件已经存在, queueName = " + queueName);
}
boolean result1 = newDataFile.createNewFile();
if (!result1) {
throw new MQException("[MessageFileManager.gc()] new_queue_data.txt 文件创建失败, newDataFile = " + newDataFile.getAbsolutePath());
}
// 3, 读取 data.txt 文件中的所有有效消息
LinkedList<Message> ValidMessages = loadAllMessageFromQueue(queueName);
// 4, 把所有有效消息写入 new_queue_data.txt
try (OutputStream outputStream = new FileOutputStream(getNewQueueDataPath(queueName))) {
try (DataOutputStream dataOutputStream = new DataOutputStream(outputStream)) {
for (Message message : ValidMessages) {
// 消息对象-->二进制消息
byte[] messageTo = BinaryUtil.toByteArray(message);
dataOutputStream.writeInt(messageTo.length);
dataOutputStream.write(messageTo);
}
}
}
// 5, 删除 data.txt, 重命名 new_queue_data.txt --> data.txt
File dataFile = new File(getQueueDataPath(queueName));
boolean result2 = dataFile.delete();
if (!result2) {
throw new MQException("[MessageFileManager.gc()] 删除 queue_data.txt 文件失败! dataFile = " + dataFile.getAbsolutePath());
}
boolean result3 = newDataFile.renameTo(dataFile);
if (!result3) {
throw new MQException("[MessageFileManager.gc()] 重命名 new_queue_data.txt 文件失败! newDataFile = " + newDataFile.getAbsolutePath());
}
// 6, 更新 stat.txt 文件
Stat stat = readStat(queueName);
stat.totalCount = ValidMessages.size();
stat.validCount = ValidMessages.size();
writeStat(queueName, stat);
// 7, 记录结束时间
long endGC = System.currentTimeMillis();
System.out.println("[MessageFileManager.gc()] 执行时间: " + (endGC - startGC) + "ms");
}
}
11, loadAllMessageFromQueue() 加载所有有效消息
把消息内容从⽂件加载到内存中. 这个功能在服务器重启, 和垃圾回收的时候都很关键
public LinkedList<Message> loadAllMessageFromQueue(String queueName) throws IOException, MQException {
LinkedList<Message> messagesLinkedList = new LinkedList<>();
try (InputStream inputStream = new FileInputStream(getQueueDataPath(queueName))){
try (DataInputStream dataInputStream = new DataInputStream(inputStream)) { //这个流可以读取字节
int currentOffsetBegin = 0;
// 循环读取所有有效消息
while (true) {
// 1, 读取消息长度
int messageLength = dataInputStream.readInt();
// 2, 读取消息内容
byte[] messageBinaryFrom = new byte[messageLength];
int messageActualLength = dataInputStream.read(messageBinaryFrom);
if (messageActualLength != messageLength) {
throw new MQException("[MessageFileManager.loadAllMessageFromQueue()] 读取消息时文件错乱, queueName = " + queueName);
}
// 3, 二进制消息-->消息对象
Message messageInFile = (Message) BinaryUtil.toObject(messageBinaryFrom);
// 4, 判断是否为有效消息
if (messageInFile.getIsValid() != 0x1) {
currentOffsetBegin += (4 + messageActualLength);
continue;
}
// 5, 由于偏移量没有序列化, 需要再消息对象中设置偏移量后再存入链表
messageInFile.setOffsetBegin(currentOffsetBegin + 4);
messageInFile.setOffsetEnd(currentOffsetBegin + 4 + messageActualLength);
messagesLinkedList.add(messageInFile);
currentOffsetBegin += 4 + messageActualLength;
}
} catch (EOFException e) {
// 这个 catch 并非真是处理 "异常", 而是处理 "正常" 的业务逻辑. 文件读到末尾, 会被 readInt 抛出该异常.
// 这个 catch 语句中也不需要做啥特殊的事情
System.out.println("[MessageFileManager.loadAllMessageFromQueue()] 读取 Message 数据完成!");
}
}
return messagesLinkedList;
}
- 使⽤ DataInputStream 读取数据. 先读 4 个字节为消息的⻓度, 然后再按照这个⻓度来读取实际消息内容.
- 读取完毕之后, 转换成 Message 对象.
- 同时计算出该对象的 offsetBegin 和 offsetEnd.
- 最终把结果整理成链表, 返回出去.
- 注意, 对于 DataInputStream 来说, 如果读取到 EOF, 会抛出⼀个 EOFException , ⽽不是返回特定值. 因此需要注意上述循环的结束条件, 抛出⼀个 EOFException 就会退出循环, 但对程序无影响
四、小结
本文主实现了两点:
- 1, 实现了序列化 / 反序列化, 为了保证数据能在文件中存储以及后续的网络传输
- 2, 持久化存储 --> 硬盘管理 --> 文件
-
- 2.1, 设计了消息在文件中存储的格式和规范
-
- 2.2, 设计了垃圾回收机制(复制算法), 防止垃圾数据持续堆积
-
- 2.3, 编写了文件管理的基本操作: 关于文件和目录的创建 / 删除, 关于消息的添加 / 删除等
上篇文章 主要实现了硬盘上数据库的管理, 本篇主要实现了营盘山文件的管理, 所以下篇文章会对数据库
和文件
的进一步整合, 封装成对硬盘上数据的统一管理, 为上层(服务器)提供 API , 并且实现对内存中数据的管理