前言:本项目是仿照RabbitMQ并基于SpringBoot + Mybatis + SQLite3实现的消息队列,该项目实现了MQ的核心功能:生产者、消费者、中间人、发布、订阅等。
源码链接:仿Rabbit MQ实现消息队列
目录
前言:本项目是仿照RabbitMQ并基于SpringBoot + Mybatis + SQLite3实现的消息队列,该项目实现了MQ的核心功能:生产者、消费者、中间人、发布、订阅等。
12.3 实现 readRequest / writeResponse
一、核心概念
关于消息队列,有几个重要的核心概念:
- 生产者(Producer) :负责将应用程序产生的数据转换成消息,并将这些消息推送到消息队列服务器上,以便消费者(Consumer)可以接收并处理这些消息。
- 消费者(Consumer):它的主要职责是监听特定的队列或主题,并对到达的消息执行必要的业务逻辑。
- 中间人(Broker):作为生产者(Producer)和消费者(Consumer)之间的中介,负责管理和协调消息的传递过程。
- 发布(Publish):生产者向中间人投递消息的过程。
- 订阅(Subscribe):哪些消费者要从这个中间人获取数据,这个注册的过程称为订阅。
在中间人(Broker)模块,又有以下几个概念:
- 虚拟机 (VirtualHost):类似于 MySQL 的 "database", 是⼀个逻辑上的集合,⼀个 BrokerServer 上可以存在多个 VirtualHost。
- 交换机 (Exchange): ⽣产者把消息先发送到 Broker 的 Exchange 上,再根据不同的规则, 把消息转发给不同的 Queue。
- 队列 (Queue): 真正⽤来存储消息的部分,每个消费者决定⾃⼰从哪个 Queue 上读取消息。
- 绑定 (Binding): Exchange 和 Queue 之间的关联关系,Exchange 和 Queue 可以理解成 "多对多" 关系,使⽤⼀个关联表就可以把这两个概念联系起来。
二、模块划分
明确需要做的工作:
-
实现生产者、消费者、Broker Server这三个部分。
-
针对生产者、消费者,主要实现的是客户端和服务器的网络通信部分。
-
给客户端提供一组 API,让客户端的业务代码来调用,通过网络通信的方式远程调用Broker Server上的方法。
-
实现Broker Server 内部的一些基本概念和API(虚拟主机、交换机、队列、绑定、消息)。
-
持久化(考虑到 SQLite 相比 MySQL 来说比较轻量,因此存储交换机、队列等这些实体用 SQLite,消息的存储使用文件进行管理)。
针对于上述所需要实现的模块,进行划分:
三、创建核心实体类
3.1 创建交换机(Exchange)
name | type | durable | autoDelete | arguments |
---|---|---|---|---|
交换机身份标识 | 交换机类型 | 是否持久化 | 是否自动删除 | 额外参数选项 |
@Data
public class Exchange {
//交换机的身份标识(唯一)
private String name;
//交换机类型 direct fanout topic
private ExchangeType type = ExchangeType.DIRECT;
//表示该交换机是否要持久化存储. true 表示需要持久化. false 表示不需要持久化
private boolean durable = false;
//如果当前交换机,无客户端使用,就自动删除
private boolean autoDelete = false;
//表示创建交换机时指定的一些额外的参数选项
private Map<String,Object> arguments = new HashMap<>();
}
此处省略 arguments 存储数据库时的Json转换,只需要使用 ObjectMapper即可实现。关于交换机的类型,此次主要实现了以下三种:DIRECT、FANOUT、TOPIC。并使用枚举类定义:
public enum ExchangeType {
DIRECT(0),
FANOUT(1),
TOPIC(2);
private final int type;
private ExchangeType(int type){
this.type = type;
}
public int getType() {
return type;
}
}
3.2 创建队列实体类(MSGQueue)
name | durable | exclusive | autoDelete | arguments | consumerEnvList | consumerSeq |
---|---|---|---|---|---|---|
队列标识 | 是否持久化 | 是否独占 | 是否自动删除 | 额外参数选项 | 当前订阅的消费者列表 | 记录当前取到第几个消费者 |
@Data
public class MSGQueue {
//表示队列的身份标识
private String name;
//表示队列是否持久化 true:需要持久化 false:不需要持久化
private boolean durable = false;
//表示是否独占,true:独占 false:都可以使用
private boolean exclusive = false;
//表示无客户端使用是,是否自动删除
private boolean autoDelete = false;
//表示扩展参数
private Map<String,Object> arguments = new HashMap<>();
//当前队列都有哪些消费者订阅了.
private List<ConsumerEnv> consumerEnvList = new ArrayList<>();
//记录当前取到的第几个消费者,方便实现轮询策略
private AtomicInteger consumerSeq = new AtomicInteger(0);
}
3.3 创建绑定实体类(Binding)
exchangeName | queueName | bindingKey |
---|---|---|
交换机名字 | 队列名字 | 绑定(和 routingKey匹配) |
@Data
public class Binding {
// 交换机名字
private String exchangeName;
//队列名字
private String queueName;
// 只在交换机类型为 TOPIC 时才有效. ⽤于和消息中的 routingKey 进⾏匹配
private String bindingKey;
}
3.4 创建消息实体类(Message)
消息存储为二进制形式,因此对消息的存储需要进行序列化处理,所以 Message 类要实现Serializable 接口。
basicProperties | body | offsetBeg | offsetEnd | isValid |
---|---|---|---|---|
消息的属性 | 消息的正文 | 消息开头在文件中的偏移量 | 消息末尾在文件中的偏移量 | 是否有效 |
@Data
public class Message implements Serializable {
private BasicProperties basicProperties = new BasicProperties();
private byte[] body;
//辅助属性,后续消息要存储在文件中
//一个文件存储很多消息 [offsetBeg, offsetEnd)
private transient long offsetBeg = 0;//消息数据的开头距离文件开头的位置偏移(字节)
private transient long offsetEnd = 0;//消息数据的结尾距离文件开头的位置偏移(字节)
private byte isValid = 0x1;//表示该消息在文件中是否是有效的消息,逻辑删除, 0x1:有效 0x0:无效
//创建一个工厂方法,让工厂方法去封装一下创建 Message 对象的过程
//这个方法创建的 Message 会自动生成一个唯一的 MessageId
public static Message createMessageWithId(String routingKey,BasicProperties basicProperties,byte[] body){
Message message = new Message();
if(basicProperties != null){
message.setBasicProperties(basicProperties);
}
//此处生成的 MessageId 以 "M-" 为前缀, 方便区分
message.setMessageId("M-" + UUID.randomUUID());
message.setRoutingKey(routingKey);
message.body = body;
return message;
}
public String getMessageId(){
return basicProperties.getMessageId();
}
public void setMessageId(String messageId){
basicProperties.setMessageId(messageId);
}
public String getRoutingKey(){
return basicProperties.getRoutingKey();
}
public void setRoutingKey(String routingKey){
basicProperties.setRoutingKey(routingKey);
}
public int getDeliverMode(){
return basicProperties.getDeliverMode();
}
public void setDeliverMode(int deliverMode){
basicProperties.setDeliverMode(deliverMode);
}
}
对于消息的属性,使用一个实体类去表示:
messageId | routingKey | deliverMode |
---|---|---|
消息的唯一身份标识 | 和(bindingKey匹配) | 是否持久化 |
@Data
public class BasicProperties implements Serializable {
//消息的唯一身份标识,使用 UUID 作为 messageId
private String messageId;
/**
* 如果当前的交换机类型是 DIRECT, 此时 routingKey 就表示要转发的队列名
* 如果当前的交换机类型是 FANOUT, 此时 routingKey 无意义(不使用)
* 如果当前的交换机类型是 TOPIC, 此时 routingKey 就表示和 bindingKey 进行匹配
*/
private String routingKey;
//表示消息是否持久化, 1: 不持久化; 2: 持久化
private int deliverMode = 1;
}
四、数据库操作
SQLite 只是把数据单纯的存储到⼀个⽂件中,因此在这里设定存储到 “./data/meta.db”文件。
实现创建表以及数据库操作(这里不再展示具体的SQL语句)
@Mapper
public interface MetaMapper {
//三个核心建表方法
void createExchangeTable();
void createQueueTable();
void createBindingTable();
//针对上述三个基本概念进行插入和删除
void insertExchange(Exchange exchange);
List<Exchange> selectAllExchanges();
void deleteExchange(@Param("exchangeName") String exchangeName);
void insertQueue(MSGQueue queue);
List<MSGQueue> selectAllQueues();
void deleteQueue(@Param("queueName") String queueName);
void insertBinding(Binding binding);
List<Binding> selectAllBindings();
void deleteBinding(Binding binding);
}
五、封装对数据库的操作
public class DataBaseManager {
//数据库初始化
public void init(){...}
//删除数据库
public void deleteDB(){...}
//判断数据库是否存在
private boolean checkDBExists(){...}
//建表操作
private void createTable(){...}
//创建默认数据,RabbitMQ 里默认也带有一个 匿名 的交换机,类型是 DIRECT
private void createDefaultData(){...}
//交换机的数据库操作:增删查
public void insertExchange(Exchange exchange){...}
public void deleteExchange(String exchangeName){...}
public List<Exchange> selectAllExchanges(){...}
//队列的数据库操作:增删查
public void insertQueue(MSGQueue queue){...}
public void deleteQueue(String queueName){...}
public List<MSGQueue> selectAllQueues(){...}
//Binding的数据库操作:增删查
public void insertBinding(Binding binding){...}
public void deleteBinding(Binding binding){...}
public List<Binding> selectAllBindings(){...}
}
六、消息的存储设计
6.1 设计思路及设定
同时,因为队列用来存储消息,因此这里约定:
- 给每个队列分配⼀个目录,目录的名字为 data + 队列名,形如 :./data/queueName
- 该目录中包含两个固定名字的⽂件:
queue_data.txt 消息数据⽂件, 用来保存消息内容。
queue_stat.txt 消息统计⽂件, 用来保存消息统计信息。(消息总个数/t有效消息数),这样设计主要时考虑到后续进行垃圾回收,方便判断进行GC的时机。
6.2 设定存储消息的格式
消息数据文件以二进制的形式存储在 queue_data.txt 文件中 ,为了方便进行消息的读取,这里进行这样的设定:
6.3 实现消息序列化工具
对于实现消息序列化,首先 Message 实体类要实现 Serializable 接口,接下来需要借助ByteArrayOutputStream 和 ObjectOutputStream 实现消息的序列化和反序列化:
public class BinaryTool {
/**
* 把对象序列化成一个字节数组
* @param object
* @return
*/
public static byte[] toBytes(Object object) throws IOException {
// 这个流对象相当于一个变长的字节数组,
// 可以把 object 对象序列化的数据逐步写入导 byteArrayOutputStream 中,
// 再统一转成 byte[]
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()){
try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream)){
// 此处的 writeObject 就会把 object 进行序列化,生成的字节数据就会写入到 objectOutputStream
// objectOutputStream 又关联到了 byteArrayOutputStream,最终结果写入到 byteArrayOutputStream 里
objectOutputStream.writeObject(object);
}
return byteArrayOutputStream.toByteArray();
}
}
/**
* 把一个字节数组反序列化成一个对象
* @param data
* @return
*/
public static Object fromBytes(byte[] data) throws IOException, ClassNotFoundException {
Object object = null;
try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data)){
try (ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream)){
object = objectInputStream.readObject();
}
}
return object;
}
}
七、实现文件管理消息
创建 MessageFileManager 类,这个类主要去实现消息统计文件的读写、消息数据文件的读写、创建存储消息的文件、消息的垃圾回收等等。
下面这段代码是 MessageFileManager 的基础代码,实现文件的读写和垃圾回收都需要调用下面的方法:
public class MessageFileManager {
//定义一个内部类,来表示该队列的统计信息
static public class Stat{
public int totalCount; // 总消息数量
public int validCount; // 有效消息数量
}
public void init(){
}
//约定消息文件所在的目录和文件名
// 所在路径及文件名: ./data/队列名/queue_data.txt(消息数据文件)
// ./data/队列名/queue_stat.txt(消息统计文件)
/**
* 这个方法用来获取指定队列对应的消息文件所在路径
* @param queueName
* @return
*/
private String getQueueDir(String queueName){
return "./data/" + queueName;
}
/**
* 这个方法用来获取该队列的消息数据文件路径
* @param queueName
* @return
*/
private String getQueueDataPath(String queueName){
return getQueueDir(queueName) + "/queue_data.txt";
}
/**
* 这个方法用来获取指定队列的消息统计文件的路径
* @param queueName
* @return
*/
private String getQueueStatPath(String queueName){
return getQueueDir(queueName) + "/queue_stat.txt";
}
7.1 读取消息统计文件
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;
}
7.2 写消息统计文件
private void writeStat(String queueName, Stat stat){
// 使用 PrintWriter 写文件
try (OutputStream outputStream = new FileOutputStream(getQueueStatPath(queueName))){
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.write(stat.totalCount + "\t" + stat.validCount);
printWriter.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
7.3 创建队列对应的文件和目录
public void createQueueFiles(String queueName) throws IOException {
//1.创建队列对应的消息目录
File baseDir = new File(getQueueDir(queueName));
if(!baseDir.exists()){
boolean success = baseDir.mkdirs();
if(!success){
throw new IOException("创建目录失败! baseDir :" + baseDir.getAbsolutePath());
}
}
//2.创建消息数据文件
File queueDataFile = new File(getQueueDataPath(queueName));
if(!queueDataFile.exists()){
boolean success = queueDataFile.createNewFile();
if(!success){
throw new IOException("创建消息数据文件失败! queueDataFile: " + queueDataFile.getAbsolutePath());
}
}
//3.创建消息统计文件
File queueStatFile = new File(getQueueStatPath(queueName));
if(!queueStatFile.exists()){
boolean success = queueStatFile.createNewFile();
if(!success){
throw new IOException("创建消息统计文件失败! queueStatFile: " + queueStatFile.getAbsolutePath());
}
}
//4.给消息统计文件设置初始值
Stat stat = new Stat();
stat.totalCount = 0;
stat.validCount = 0;
writeStat(queueName,stat);
}
7.4 删除队列对应的文件和目录
public void destroyQueueFiles(String queueName) throws IOException {
//先删除文件,再删除目录
File queueDataFile = new File(getQueueDataPath(queueName));
boolean success1 = queueDataFile.delete();
File queueStatFile = new File(getQueueStatPath(queueName));
boolean success2 = queueStatFile.delete();
File baseDir = new File(getQueueDir(queueName));
boolean success3 = baseDir.delete();
if(!success1 || !success2 || !success3){
//删除失败
throw new IOException("删除队列目录和消息文件失败! baseDir: " + baseDir.getAbsolutePath());
}
}
7.5 判断队列的目录和消息文件是否存在
public boolean checkFileExists(String queueName){
//判断队列的 消息数据文件 和 消息统计文件 是否都存在
File queueDataFile = new File(getQueueDataPath(queueName));
File queueStatFile = new File(getQueueStatPath(queueName));
if(queueDataFile.exists() && queueStatFile.exists()){
return true;
}
return false;
}
7.6 新的消息写入到文件中
步骤:
- 检查要写入的队列对应的文件是否存在
- 对 Message 对象进行序列化
- 获取当前消息数据文件的长度,由此来设置当前要写入的消息的 offsetBeg 和 offsetEnd。
offsetBeg = 消息数据文件长度 + 4
offsetEnd = 消息数据文件长度 + 4 + 该消息序列化后的 byte 数组的长度
- 写入消息数据文件,更新消息统计文件
public void sen