从零手搓一个【消息队列】实现消息在文件中的存储


创建 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, 队列目录

消息需要在硬盘上存储. 但是并不直接放到数据库中, ⽽是直接使⽤⽂件存储
原因如下:

  1. 对于消息的操作并不需要复杂的 增删改查
  2. 对于⽂件的操作效率⽐数据库会⾼很多

主流 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 , 并且实现对内存中数据的管理


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

灵魂相契的树

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值