【项目篇之消息序列化】仿照RabbitMQ模拟实现消息队列

实现消息序列化

什么叫做序列化?
把一个对象,也就是一个结构化的数据,给转换成一个字符串/字节数组

什么是反序列化
把一个字符串/字节数组给转换为一个对象/一个结构化数据

我们需要保证在完成序列化之后,对象的信息是不丢失的,对象中的所有信息在序列化之后,都会被保存到字符串/字节数组中的

如此之后,才可以在后面进行反序列化

序列化的目的就是为了最终进行反序列化,序列化是为了方便存储和传输

存储就是在文件中存储,因为文件只能存字符串/二进制数据,文件是不能直接存储对象,需要把对象通过序列化转换成一个字符串

为什么不使用JSON来序列化

我们之前是使用了JSON来完成序列化和反序列化

由于Message里面存储的body部分是二进制数据,不方便使用JSON进行序列化

因为JSON序列化得到的结果是文本数据,无法存储二进制数据:

在JSON格式中有很多特殊符号,会影响到JSON格式的解析,所以JSON格式不能存储二进制

所以我们不使用JSON进行序列化,

我们直接使用二进制的序列化方式,针对Message对象进行序列化

直接使用二进制序列化

针对二进制序列化有很多解决方案,我们就采取最直接的一种:

使用Java标准库中的提供的序列化方案:ObjectInputStream和ObjectOutputStream

这样不用引入额外的依赖了,其他的方案需要引入额外的依赖:

  1. protobuffer
  2. thrift

我们将序列化操作都编写在一个BinaryTool类中去:

这种序列化操作是偏向于一种公共的代码,客户端和服务器都需要使用到序列化,所以我们就直接把这个序列化的代码编写到公共目录下即可:

  1. 序列化:ObjectOutputStream
  2. 反序列化:ObjectInputStream

实现序列化方法toBytes()

下面我们去实现一个序列化方法toBytes():

这个toBytes方法的具体流程如下所示:

1: 创建内存缓冲区​​

java 复制代码
try(ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) { ... }

​​作用​​:ByteArrayOutputStream 是一个内存中的字节容器,用来临时存储序列化后的二进制数据。

​​类比​​:就像快递打包时使用的空纸箱,用来装载物品(对象的二进制数据)

2 :创建对象序列化通道​

java 复制代码
try(ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream)) { ... }

​​作用​​:ObjectOutputStream 是对象序列化的核心工具,负责将Java对象转换为二进制流。

​​流程 : 通过构造函数将 ObjectOutputStream 与 ByteArrayOutputStream 绑定,形成数据传输管道(类似给纸箱贴上快递单)

ObjectOutputStream 会自动写入序列化协议头(标识该流是序列化数据)

3:执行序列化操作​

java 复制代码
objectOutputStream.writeObject(object);

4:提取二进制数据,转换成byte[]

java 复制代码
return byteArrayOutputStream.toByteArray();  

序列化图示流程:

序列化完整代码:

java 复制代码
//序列化:把一个对象序列化为一个字节数组  
public static byte[] toBytes(Object object) throws IOException {  
    //这个流对象类似于一个变长的字节数组:  
    //可以把Object序列化的数据给逐渐地写入到byteArrayOutputStream中,然后再统一转成byte[]  
    try(ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()){  
        try(ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream)){  
            //此处的writeObject就会把该对象进行序列化,生成的二进制字节数据,就会写入到ObjectOutputStream中  
            //由于ObjectOutputStream是关联着ByteArrayOutputStream  
            //所以结果就直接写入到了ByteArrayOutputStream中:  
            objectOutputStream.writeObject(object);  
        }  
        //这个操作是把byteArrayOutputStream中持有的二进制数据取出来,转换成byte[]  
        return byteArrayOutputStream.toByteArray();  
    }  
}

反序列化方法fromBytes():

1 :创建内存输入流​

java 复制代码
try(ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data)) { ... }

​​作用​​ :将 byte[] 数据包装为内存输入流,允许按字节顺序读取数据

​​类比​​:就像把打包好的快递箱拆封后摆上传输带

2 : 创建对象反序列化通道

java 复制代码
try(ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)) { ... }

3 : 执行反序列化操作​

java 复制代码
object = objectInputStream.readObject();

图示流程:

完整代码:

java 复制代码
//反序列化:把一个字节数组反序列化为一个对象:  
private static Object fromBytes(byte[] data) throws IOException, ClassNotFoundException {  
    Object object;  
    try(ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data)){  
        try(ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)){  
            //此处的readObject就是从data这个byte[]中读取的数据并进行反序列化:  
            object = objectInputStream.readObject();  
        }  
    }  
    return object;  
}

实现Serializable接口

如果希望这个类可以实现序列化和反序列化,就需要让这个类去实现一个接口:Serializable:

最后这个类的代码如下所示:

java 复制代码
package org.example.mq.common;  
  
import java.io.*;  
  
public class BinaryTool implements  Serializable {  
  
    //序列化:把一个对象序列化为一个字节数组  
    public static byte[] toBytes(Object object) throws IOException {  
        //这个流对象类似于一个变长的字节数组:  
        //可以把Object序列化的数据给逐渐地写入到byteArrayOutputStream中,载统一转成byte[]  
        try(ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()){  
            try(ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream)){  
                //此处的writeObject就会把该对象进行序列化,生成的二进制字节数据,就会写入到ObjectOutputStream中  
                //由于ObjectOutputStream是关联着ByteArrayOutputStream  
                //所以结果就直接写入到了ByteArrayOutputStream中:  
                objectOutputStream.writeObject(object);  
            }  
            //这个操作是把byteArrayOutputStream中持有的二进制数据取出来,转换成byte[]  
            return byteArrayOutputStream.toByteArray();  
        }  
    }  
  
  
    //反序列化:把一个字节数组反序列化为一个对象:  
    private static Object fromBytes(byte[] data) throws IOException, ClassNotFoundException {  
        Object object;  
        try(ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data)){  
            try(ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)){  
                //此处的readObject就是从data这个byte[]中读取的数据并进行反序列化:  
                object = objectInputStream.readObject();  
            }  
        }  
        return object;  
    }  
}

把消息写入文件中

在MessageFileManager类中写这样的一个方法:

方法的大致步骤如下所示:

  1. 检查要写入的文件是否存在
  2. 把Message对象进行序列化,转成二进制字节数组
  3. 获取到队列数据文件的长度
  4. 计算出Message对象的offsetBeg和offsetEnd,来看看从队列的哪一个位置写入对象
  5. 开始将消息写入到队列,追加写(true)
  6. 先写消息长度
  7. 再写消息本体
  8. 最后通过stat更新消息的统计文件
java 复制代码
//这个方法用来把一个新的消息放到队列对应的文件中  
//第一个参数是写入的目的队列,第二个参数是写入的消息  
public void sendMessage(MSGQueue queue, Message message) throws MqException, IOException {  
    //检查一下当前要写入的队列对应的文件是否存在:  
   if(!checkFilesExits(queue.getName())){  
       throw new MqException("[MessageFileManager] 队列对应的文件不存在!queueName:"+queue.getName());  
   }  
   //2.把Message对象,进行序列化,转成二进制的字节数组:  
    byte[] messageBinary  = BinaryTool.toBytes(message);  
   //3.先获取到当前的队列数据文件的长度,用这个来计算出Message对象的offsetBeg和offsetEnd  
   //把新的Message数据写入到队列数据文件的末尾,此时Message对象的offsetBeg就是当前文件长度+4  
   //offsetEnd就是当前文件长度 + 4 + message资深的长度:  
    File queueDataFile = new File(getQueueDataPath(queue.getName()));  
    //通过length方法获取数据文件的长度,单位是字节:  
    //计算出offsetBeg和offsetEnd:  
    message.setOffsetBeg(queueDataFile.length() + 4);  
    message.setOffsetEnd(queueDataFile.length() + 4 + messageBinary.length);  
    //4.开始写入消息到数据文件:此处是追加写,不是覆盖写(加上了true参数)  
    try(OutputStream outputStream  = new FileOutputStream(queueDataFile,true)){  
        try(DataOutputStream dataOutputStream = new DataOutputStream(outputStream)){  
            //接下来写入消息的消息长度,占据4个字节:  
            dataOutputStream.writeInt(messageBinary.length);  
            //写入消息体:  
            dataOutputStream.write(messageBinary);  
        }  
    }  
    //5.更新消息统计文件:  
    Stat stat = readStat(queue.getName());  
    stat.totalCount += 1;  
    stat.validCount += 1;  
    writeStat(queue.getName(),stat);  
}

线程安全问题:

方法中如果多个客户端同时向队列中写入消息的时候,会出现线程安全的问题

我们现在写的是一个消息队列服务器,服务器就会对应到多个客户端,会出现多个线程调用message的情况,同时调用这个sendMessage方法去往队列中写入消息

如图所示:

还有一个线程安全问题:

我们方法中的如下代码也会出现线程安全问题:

java 复制代码
//5.更新消息统计文件:  
Stat stat = readStat(queue.getName());  
stat.totalCount += 1;  
stat.validCount += 1;  
writeStat(queue.getName(),stat);  

这个代码和我们之前讲过的博客文章中出现的线程安全问题类似:【多线程】之线程安全问题

目前解决线程安全问题的方法就是去加锁

具体以哪个对象加锁,当线程遇到锁对象的时候就进行阻塞等待,以此来解决线程安全问题

我们当前就使用队列对象作为锁对象进行加锁即可

如果两个线程,在同一时刻,是往同一个队列中写入消息,那么就要阻塞等待

如果两个线程,在同一时刻,是往不同的队列中写入消息,就不需要阻塞等待

不同队列是不同的文件,各写各的,不会出现线程安全问题了

我们把代码中的写入消息到队列中这个整个过程进行加锁操作即可

如下代码所示:

java 复制代码
//这个方法用来把一个新的消息放到队列对应的文件中  
//第一个参数是写入的目的队列,第二个参数是写入的消息  
public void sendMessage(MSGQueue queue, Message message) throws MqException, IOException {  
    //检查一下当前要写入的队列对应的文件是否存在:  
   if(!checkFilesExits(queue.getName())){  
       throw new MqException("[MessageFileManager] 队列对应的文件不存在!queueName:"+queue.getName());  
   }  
   //2.把Message对象,进行序列化,转成二进制的字节数组:  
    byte[] messageBinary  = BinaryTool.toBytes(message);  
   synchronized (queue){  
      //3.先获取到当前的队列数据文件的长度,用这个来计算出Message对象的offsetBeg和offsetEnd  
      //把新的Message数据写入到队列数据文件的末尾,此时Message对象的offsetBeg就是当前文件长度+4  
      //offsetEnd就是当前文件长度 + 4 + message资深的长度:  
      File queueDataFile = new File(getQueueDataPath(queue.getName()));  
      //通过length方法获取数据文件的长度,单位是字节:  
      //计算出offsetBeg和offsetEnd:  
      message.setOffsetBeg(queueDataFile.length() + 4);  
      message.setOffsetEnd(queueDataFile.length() + 4 + messageBinary.length);  
      //4.开始写入消息到数据文件:此处是追加写,不是覆盖写(加上了true参数)  
      try(OutputStream outputStream  = new FileOutputStream(queueDataFile,true)){  
          try(DataOutputStream dataOutputStream = new DataOutputStream(outputStream)){  
              //接下来写入消息的消息长度,占据4个字节:  
              dataOutputStream.writeInt(messageBinary.length);  
              //写入消息体:  
              dataOutputStream.write(messageBinary);  
          }  
      }  
      //5.更新消息统计文件:  
      Stat stat = readStat(queue.getName());  
      stat.totalCount += 1;  
      stat.validCount += 1;  
      writeStat(queue.getName(),stat);  
  }  
  
}

我们现在加锁的时候为什么出现警告呢?

删除消息

删除消息的方法,是逻辑删除,也就是把硬盘上存储的这个数据里面的那个isValid属性设置为0:

这个删除消息的方法的具体步骤:

  1. 先把文件中的这一段数据给读出来,还原回Message对象
  2. 把isValid修改成0
  3. 把上述数据重新写回到文件中

此处这个参数中的message对象,必须得包含有效的offsetBeg和offsetEnd:

1: 先把文件中的数据读取出来,还原回Message对象:

我们之前使用的FileInputStream和FileOutputStream都是从文件头开始读写的

但是此处读取数据我们需要进行随机访问(在文件中的指定位置进行读取)

使用的是另一个类:RandomAccessFile:

随机访问

内存就支持随机访问,内存上的随机访问就是访问内存上面的任意一个地址,开销成本都一样

这也是为什么数组可以取下标同时时间复杂度是O(1)的原因

硬盘也能够支持随机访问,但是硬盘的随机访问的成本比内存高得多

RandomAccessFile这个类所提供的方法:

  1. read
  2. write
  3. seek:调整当前的文件光标(当前要读写的位置)

seek可以移动文件光标,同时read和write也会引起文件光标的移动的,

删除消息的方法的具体步骤如下所示:

下面开始编写代码:

java 复制代码
//删除消息的方法:将isValid属性设置为0:  
public void deleteMessage(MSGQueue queue, Message message) throws IOException, ClassNotFoundException {  
    try(RandomAccessFile randomAccessFile = new RandomAccessFile(getQueueDataPath(queue.getName()), "rw")){  
        //1.先从文件中读取对应的Message数据:  
        //读取操作肯定是从硬盘读取到内存上  
        //按照要读取的Message的长度去创建一个对应的byte空间出来:  
        byte[] bufferSrc = new byte[(int) (message.getOffsetEnd() - message.getOffsetBeg())];  
        //指定文件光标。指定到message开始的位置:  
        randomAccessFile.seek(message.getOffsetBeg());  
        //从光标开始的位置,也就是message开始的位置进行读取:读取一个bufferSrc大小空间的数据:  
        randomAccessFile.read(bufferSrc);  
        //2.把当前从硬盘上读取过来的数据(读取出来的二进制数据)给转换成Message对象:(反序列化)  
        Message diskMessage =(Message)(BinaryTool.fromBytes(bufferSrc));  
        //3.把isValid设置为无效(0):逻辑删除:  
        diskMessage.setIsValid((byte)(0x0));  
        //4.将删除完毕的数据重新写入文件:序列化:  
        byte[] bufferDest = BinaryTool.toBytes(diskMessage);  
        //虽然刚刚已经seek过,但是刚刚seek完了之后,进行了读操作,导致光标变了:  
        //所以需要重新回到原来的Message数据开始的位置:  
        randomAccessFile.seek(message.getOffsetBeg());  
        //在原来Message的位置重新写入删除完毕之后的数据:  
        randomAccessFile.write(bufferDest);  
    }  
    //更新统计文件,我们把一个消息设置为无效了,此时总消息个数不变,但是有效消息个数就减一:  
	Stat stat = readStat(queue.getName());  
	if(stat.validCount > 0){  
		stat.validCount -= 1;  
	}  
	writeStat(queue.getName(),stat);
}

同时这个删除消息的方法和刚刚的把消息写入到文件中的方法一样,也会出现线程安全的问题:

所以也是需要针对锁对象(队列对象)进行加锁操作的:

java 复制代码
  
//删除消息的方法:将isValid属性设置为0:  
public void deleteMessage(MSGQueue queue, Message message) throws IOException, ClassNotFoundException {  
    synchronized (queue) {  
        try (RandomAccessFile randomAccessFile = new RandomAccessFile(getQueueDataPath(queue.getName()), "rw")) {  
            //1.先从文件中读取对应的Message数据:  
            //读取操作肯定是从硬盘读取到内存上  
            //按照要读取的Message的长度去创建一个对应的byte空间出来:  
            byte[] bufferSrc = new byte[(int) (message.getOffsetEnd() - message.getOffsetBeg())];  
            //指定文件光标。指定到message开始的位置:  
            randomAccessFile.seek(message.getOffsetBeg());  
            //从光标开始的位置,也就是message开始的位置进行读取:读取一个bufferSrc大小空间的数据:  
            randomAccessFile.read(bufferSrc);  
            //2.把当前从硬盘上读取过来的数据(读取出来的二进制数据)给转换成Message对象:(反序列化)  
            Message diskMessage = (Message) (BinaryTool.fromBytes(bufferSrc));  
            //3.把isValid设置为无效(0):逻辑删除:  
            diskMessage.setIsValid((byte) (0x0));  
            //4.将删除完毕的数据重新写入文件:序列化:  
            byte[] bufferDest = BinaryTool.toBytes(diskMessage);  
            //虽然刚刚已经seek过,但是刚刚seek完了之后,进行了读操作,导致光标变了:  
            //所以需要重新回到原来的Message数据开始的位置:  
            randomAccessFile.seek(message.getOffsetBeg());  
            //在原来Message的位置重新写入删除完毕之后的数据:  
            randomAccessFile.write(bufferDest);  
        }  
        //更新统计文件,我们把一个消息设置为无效了,此时总消息个数不变,但是有效消息个数就减一:  
        Stat stat = readStat(queue.getName());  
        if (stat.validCount > 0) {  
            stat.validCount -= 1;  
        }  
        writeStat(queue.getName(), stat);  
    }  
}

删除方法中声明的参数对象Message message这个对象是在内存上管理的消息对象

而我们在方法里面写的diskMessage对象是硬盘上管理的消息对象

这个删除方法什么时候调用呢,就是当我们需要删除消息的时候就会去调用这个删除消息的方法,删除消息的时机就是消费者将这个消息给正确处理了之后就需要把这个消息给删除掉了

这个删除就是要把硬盘上的Message对象和内存上的Message对象都全部进行删除

而我们刚刚写的这个删除方法deleteMessage方法是删除的是硬盘上面的Message对象的

isValid属性只是用来在文件中标识这个消息有效还是无效的作用,这个属性在内存中不起作用

内存中删除Message对象只需要使用集合类来删除即可

所以此处就不需要给这个参数的Message对象的isValid设置为无效了,因为这个参数代表的是内存上管理的Message对象,这个对象很快就会从内存上被销毁了

加载文件中所有的信息

下面这个方法的目的是把所有的文件都读取出来,加载到内存当中去

这个方法准备在程序启动的时候去进行调用:

希望在brokerServer重启了之后,内存上的数据都不会丢失,这个可以把之前保存的消息都能够还原到内存中去:

服务器重启的时候,可以把整个文件中所有的消息单独拎出来放到这个LinkedList链表中,交给内存管理器去负责管理:

使用LinkedList主要是为了进行头删操作:

这个方法的参数只是一个String的queueName,没有传递queue对象,是因为只需要使用到这个queueName,不需要使用到queue对象

而且这个方法不会出现线程安全问题,不需要进行加锁操作,因为这个方法是在程序启动的时候才去调用的,不涉及多线程操作文件

java 复制代码
//这个方法目的是把所有的消息内容读取出来加载到内存中  
//这个方法准备在程序启动的时候调用  
public LinkedList<Message> loadAllMessageFromQueue(String queueName) throws IOException, MqException, ClassNotFoundException {  
    LinkedList<Message> messages = new LinkedList<>();  
    try(InputStream inputStream = new FileInputStream(getDataPath(queueName))){  
       try(DataInputStream dataInputStream  = new DataInputStream(inputStream)){  
           //使用currentOffset记录当前文件光标  
           long currentOffset = 0;  
           //一个文件中包含了很多的信息所以需要循环读取消息  
           while(true){  
               //1.读取当前消息的长度  
               // readInt方法读取到文件末尾,会抛出EOFException异常,这一点和之前的很多流对象都不一样  
               int messageSize = dataInputStream.readInt();  
               //2. 按照这个读取到的消息长度去读取消息的内容  
               byte[] buffer = new byte[messageSize];  
               int actualSize = dataInputStream.read(buffer);  
               if(messageSize != actualSize){  
                   //大小不匹配,说明文件格式错乱了  
                   throw new MqException("[MessageFileManager] 文件格式错误,queueName = " + queueName);  
               }  
               //3.把读取到的这个二进制数据给反序列化为Message对象  
               Message message = (Message)BinaryTool.fromBytes(buffer);  
               //4. 判定一下这个消息对象是不是无效对象  
               if(message.isValid() != 0x1){  
                   //无效对象,直接跳过,不读取无效数据  
                   // 虽然消息是无效数据的,但是offset不要忘记更新  
                   currentOffset  += (4 + messageSize);  
                   continue;  
               }  
               //5. 如果是有效数据,就把当前这个Message对象加入到链表中,加入之前还需要填写offsetBeg和offsetEnd  
               // 进行计算offset的时候,需要知道当前文件光标的位置的,由于当下使用的DataInputStream不方便直接获取到文件光标  
               //因此就需要手动计算出文件光标  
               message.setOffsetBeg(currentOffset + 4);  
               message.setOffsetEnd(currentOffset + 4 + messageSize);  
               currentOffset  += (4 + messageSize);  
               messages.add(message);  
           }  
        }catch(EOFException e){  
           //这个catch不是处理异常,而是处理正常的业务逻辑,文件读取到末尾的时候,会被readInt抛出这个异常  
           // 这个catch异常中不需要做任何特殊处理,只是代表我们的文件是正常读取结束了  
           System.out.println("[MessageFileManager] 恢复Message数据完成了");  
       }  
         
    }  
    return messages;  
}

一般情况下,异常表示的是出乎意料的事情,异常的定义是正常业务逻辑之外的事情,预期之外的事情,出乎意料的事情,

相关推荐
回家路上绕了弯1 天前
深入解析Agent Subagent架构:原理、协同逻辑与实战落地指南
分布式·后端
用户8307196840821 天前
Spring Boot 集成 RabbitMQ :8 个最佳实践,杜绝消息丢失与队列阻塞
spring boot·后端·rabbitmq
用户8307196840823 天前
RabbitMQ vs RocketMQ 事务大对决:一个在“裸奔”,一个在“开挂”?
后端·rabbitmq·rocketmq
初次攀爬者4 天前
RabbitMQ的消息模式和高级特性
后端·消息队列·rabbitmq
初次攀爬者6 天前
ZooKeeper 实现分布式锁的两种方式
分布式·后端·zookeeper
让我上个超影吧7 天前
消息队列——RabbitMQ(高级)
java·rabbitmq
塔中妖7 天前
Windows 安装 RabbitMQ 详细教程(含 Erlang 环境配置)
windows·rabbitmq·erlang
断手当码农7 天前
Redis 实现分布式锁的三种方式
数据库·redis·分布式
初次攀爬者7 天前
Redis分布式锁实现的三种方式-基于setnx,lua脚本和Redisson
redis·分布式·后端
业精于勤_荒于稀7 天前
物流订单系统99.99%可用性全链路容灾体系落地操作手册
分布式