MySQL Binlog、Redis、WebSocket实现实时数据推送

一、 概述

         如下流程图中,描述了一个基于Spring Boot,利用MySQL的二进制日志(binlog)来实时监听数据库中的数据变化,同时结合了RedisWebSocket,模块之间相互协作,实现实时数据监听和推送功能。

      流程示意图


二、 代码实现

首先,我们需要引入 mysql-binlog-connector-java 依赖,以便能够监听 MySQL 的二进制日志:

<dependency>
	<groupId>com.github.shyiko</groupId>
	<artifactId>mysql-binlog-connector-java</artifactId>
	<version>0.21.0</version>
</dependency>

接下来,我们创建一个 BinlogToRedisConfig 类来配置和启动 Binlog 监听器。

该配置类用于将MySQLbinlog事件同步到Redis中。它通过创建和配置一个BinaryLogClient来订阅和处理MySQLbinlog事件。在binaryLogClient方法中,它从数据库连接URL中提取数据库配置信息,并创建一个BinaryLogClient实例来连接到MySQL。此外,设置了一个事件监听器,用于处理不同类型的binlog事件,如表映射事件、更新行事件、插入行事件和删除行事件。在事件监听器中,根据事件类型和数据库配置信息,将相应的事件数据发布到Redis中。在startBinaryLogClient方法中,它启动了BinaryLogClient的连接线程,以异步地连接到MySQLBinary Log。此外,还提供了一个extractDatabaseInfo方法,用于从数据库连接URL中提取数据库配置信息。

import com.github.shyiko.mysql.binlog.BinaryLogClient;
import com.github.shyiko.mysql.binlog.event.*;
import com.ruoyi.common.core.redis.RedisPublisher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.github.shyiko.mysql.binlog.event.deserialization.EventDeserializer;
import org.springframework.beans.factory.annotation.Value;

import java.util.HashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Configuration
public class BinlogToRedisConfig  {


    @Value("${spring.datasource.druid.master.url}")
    private String datasourceUrl;

    @Value("${spring.datasource.druid.master.username}")
    private String username;

    @Value("${spring.datasource.druid.master.password}")
    private String password;

    @Autowired
    private RedisPublisher redisPublisher;


    /**
     * 创建并配置BinaryLogClient,用于订阅和处理MySQL的binlog事件。
     * 注册事件监听器,对不同类型的事件进行处理,如表映射事件、更新行事件、插入行事件和删除行事件。
     *
     * @return 配置好的BinaryLogClient实例。
     */
    @Bean
    BinaryLogClient binaryLogClient() {
        HashMap<String, String> config = extractDatabaseInfo(datasourceUrl);
        BinaryLogClient client = new BinaryLogClient(config.get("ip"), Integer.parseInt(config.get("port")), username, password);

        EventDeserializer deserializer = new EventDeserializer();
        deserializer.setCompatibilityMode(EventDeserializer.CompatibilityMode.DATE_AND_TIME_AS_LONG);
        client.setEventDeserializer(deserializer);

        client.registerEventListener(event -> {

            EventHeader header = event.getHeader();
            EventData data = event.getData();
            if (data instanceof TableMapEventData tableMapEventData) {
                // 数据库数据发生变化
                if (config.get("databaseName").equals(tableMapEventData.getDatabase()) && EventType.TABLE_MAP == header.getEventType()) {
                    // 可以继续指定数据库中的具体表
                    System.out.println(tableMapEventData.getTable());
                    redisPublisher.publish("db_changes", "table_map:" + tableMapEventData.getTableId() + ":" + tableMapEventData.getDatabase() + ":" + tableMapEventData.getTable());
                }
            }
            // 也可以监听对应的操作类型
            if (data instanceof UpdateRowsEventData) {
                System.out.println("Update:" + data.toString());
            }
            if (data instanceof WriteRowsEventData) {
                System.out.println("Insert:" + data.toString());
            }
            if (data instanceof DeleteRowsEventData) {
                System.out.println("Delete:" + data.toString());
            }
        });

        return client;
    }


    /**
     * 初始化并启动BinaryLogClient。
     * 通过创建一个新的线程来异步地连接到MySQL的Binary Log,可以避免连接过程中的任何延迟影响到应用程序的启动时间。
     */
    @Bean
    public void startBinaryLogClient() {
        BinaryLogClient client = binaryLogClient();
        new Thread(() -> {
            try {
                client.connect();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }).start();
    }


    /**
     * 从数据库连接URL中提取数据库配置信息。
     *
     * @param url 数据库的连接URL,遵循jdbc:mysql://主机:端口/数据库名的格式。
     * @return 返回一个HashMap,包含提取的数据库配置信息,包括主机(ip)、端口(port)和数据库名(databaseName)。
     */
    public static HashMap<String,String> extractDatabaseInfo(String url) {
        HashMap<String, String> config = new HashMap<>();
        // 定义正则表达式
        String regex = "jdbc:mysql://([^:]+):(\\d+)/([^?#]+)";
        Pattern pattern = Pattern.compile(regex);
        Matcher matcher = pattern.matcher(url);

        if (matcher.find()) {
            String ip = matcher.group(1);
            int port = Integer.parseInt(matcher.group(2));
            String databaseName = matcher.group(3);

            config.put("ip", ip);
            config.put("port", String.valueOf(port));
            config.put("databaseName", databaseName);
        }
        return config;
    }

}

为了在 Redis 中处理消息发布和订阅,我们需要配置 RedisMessageListenerContainer 和相关的发布者与订阅者类。

Redis配置类,用于配置缓存和消息监听。通过构造函数,赋值redisSubscriber
redisMessageListenerContainer方法,通过@Bean注解标识为一个Bean对象。接收一个RedisConnectionFactory对象作为参数,用于创建Redis连接。在方法内部,创建了RedisMessageListenerContainer对象,并将其连接工厂设置为传入的参数。然后,向该监听容器添加了一个消息监听器redisSubscriber,用于监听名为db_changes的频道上的消息。

@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport{

    @Autowired
    private final RedisSubscriber redisSubscriber;

    public RedisConfig(RedisSubscriber redisSubscriber) {
        this.redisSubscriber = redisSubscriber;
    }

	/**
     * 创建Redis消息监听容器 bean。
     * 并监听特定频道的消息。
     *
     * @param connectionFactory Redis连接工厂,用于创建Redis连接。
     * @return RedisMessageListenerContainer,消息监听容器。
     */
    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        // 添加订阅频道
        container.addMessageListener(redisSubscriber, new PatternTopic("db_changes"));
        return container;
    }
    
    // 其他配置
    ...xxxx...
}

创建一个 RedisPublisher 类,用于向 Redis 发布消息。

提供了一个方法来向指定的Redis频道发布消息。类的构造函数接收一个StringRedisTemplate对象作为参数,并将其赋值redisTemplate。publish方法接收两个参数,分别是Redis频道的名称和要发布的消息内容,然后通过调用redisTemplateconvertAndSend方法,将消息发送到指定的频道。

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

/**
 * Redis发布者类,用于向指定的Redis频道发布消息。
 */
@Service
public class RedisPublisher {

    private final StringRedisTemplate redisTemplate;

    public RedisPublisher(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 向指定的Redis频道发布消息。
     *
     * @param channel Redis频道名称。
     * @param message 要发布的消息内容。
     */
    public void publish(String channel, String message) {
        redisTemplate.convertAndSend(channel, message);
    }
}

创建一个 RedisSubscriber 类,用于接收和处理 Redis 消息.

抽象类,实现了Redis的MessageListener接口,用于订阅Redis消息。其中,onMessage方法会在收到消息时被调用。该方法将接收到的消息转换为字符串格式的channelcontent,并调用抽象方法handleMessage来处理消息。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

/**
 * Redis消息订阅者
 */
//@Component
public abstract class RedisSubscriber implements MessageListener {

    @Autowired
    private StringRedisTemplate redisTemplate;


    @Override
    public void onMessage(Message message, byte[] pattern) {
        String channel = new String(pattern);
        String content = redisTemplate.getStringSerializer().deserialize(message.getBody());
        // 处理接收到的消息
        handleMessage(channel, content);
    }

    public abstract void handleMessage(String channel, String content);
}

引入websocket依赖

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-websocket</artifactId>
  </dependency>

创建一个 WebSocketServer 类,用于处理 WebSocket 连接和消息。

WebSocket服务器端点,用于与客户端建立WebSocket连接并进行通信。它继承了RedisSubscriber类,并实现了OnOpenOnCloseOnMessageOnErrorWebSocket生命周期的方法。以下是该类的主要功能:
建立WebSocket连接:通过@ServerEndpoint注解指定服务器端点的路径为/ws/{sid},其中sid为保留字段。在@OnOpen方法中,与客户端的连接建立成功时执行相应的逻辑,如将当前WebSocketServer实例添加到webSocketSet集合中,增加在线数量,并启动定时任务。
关闭WebSocket连接:在@OnClose方法中,当客户端连接关闭时执行相应的逻辑,如从webSocketSet集合中移除当前WebSocketServer实例,减少在线数量。
处理客户端发送的消息:在@OnMessage方法中,接收客户端发送的消息并进行处理。如果接收到的消息是心跳包,则直接返回;否则,根据消息内容执行相应的业务逻辑,并通过sendMessage方法将处理结果发送给客户端。
异常处理:在@OnError方法中,处理WebSocket连接发生错误时的逻辑,如记录错误日志。
群发消息:提供sendInfo方法用于向所有连接的客户端发送消息。
指定会话推送:提供sendInfo方法用于向指定会话的客户端发送消息。
数据处理:根据接收到的消息内容,调用相应的业务服务,获取数据并转换为JSON格式,最后将数据发送给客户端。
该类使用了CopyOnWriteArraySet来存储所有连接的客户端的WebSocketServer实例,使用ScheduledExecutorService来定时发送心跳包和处理业务逻辑。同时,它还利用AtomicBooleanAtomicInteger来控制数据推送的状态和在线数量的线程安全操作。

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.core.redis.RedisSubscriber;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.spring.SpringUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.lang.Object;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * WebSocket服务类,用于与客户端建立WebSocket连接并进行通信。
 * 继承自RedisSubscriber,实现特定的订阅和处理逻辑。
 */
@Slf4j
@Component
@ServerEndpoint(value = "/ws/{sid}")
public class WebSocketServer extends RedisSubscriber {

    /**
     * ObjectMapper
     */
    private static final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 记录在线客户端的数量。
     */
    private static final AtomicInteger onlineCount = new AtomicInteger(0);

    /**
     * 存放每个客户端对应的 WebSocketServer 对象
     */
    private static CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<>();

    /**
     * 与某个客户端的连接会话,需要通过它来给客户端发送数据
     */
    private Session session;

    /**
     * 心跳报文
     */
    private static final String HEARTBEAT_PACKETS = "The heartbeat packets";

    /**
     * 定时器
     */
    private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

    /**
     * 标志位,用于控制数据推送的开关[根据具体业务]
     */
    private static final AtomicBoolean flag = new AtomicBoolean(true);
    

    /**
     * 当WebSocket连接打开时调用。
     *
     * @param session WebSocket会话对象
     * @param sid 保留字段
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("sid") String sid) {
        // 保留字段
        String id = sid;
        this.session = session;
        // 加入set中
        webSocketSet.add(this);
        // 在线数加1
        onlineCount.getAndIncrement();
        // 新连接,刷新状态,首次推送
        flag.set(true);
        System.out.println("新连接,连接总数" + webSocketSet.size() + "---" + onlineCount.toString());
    }

    /**
     * 当WebSocket连接关闭时调用。
     */
    @OnClose
    public void onClose() {
        // 从客户端集合中移除当前实例
        webSocketSet.remove(this);
        // 在线数量减1
        onlineCount.getAndDecrement();
        System.out.println("断开连接,连接总数" + webSocketSet.size() + "---" + onlineCount.toString());
    }

    /**
     * 当WebSocket连接发生错误时调用。
     *
     * @param session WebSocket会话对象
     * @param error 错误异常
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("[历史数据回放] - WS 异常断开", session, error);
    }

    /**
     * 当收到客户端消息时调用。
     *
     * @param message 客户端发送的消息
     * @param session WebSocket会话对象
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        if (StringUtils.isEmpty(message)) return;
        if (HEARTBEAT_PACKETS.equals(message)) {
            log.debug("[消息订阅] - 心跳.");
            return;
        }
        WebSocketServer webSocketServer = webSocketSet.stream().filter(it -> it.session.equals(session)).toList().get(0);
        webSocketServer.scheduler.scheduleAtFixedRate(() -> {
        // 根据具体业务实现,这里的定时器用于控制触发频率
            try {
                // 如果连接已断开或标志位为false,则不处理
//                if (webSocketSet.stream().filter(it -> it.session.equals(session)).toList().isEmpty()) {
//                    log.debug("WebSocketSet is empty, stopping the scheduled task.");
//                    webSocketServer.scheduler.shutdownNow(); // 如果断开socket连接,关闭推送任务
//                    return;
//                }
                if (!flag.get()) return;
                try {
                    xxxxxx...........
                    sendInfo(session, xxxxxxx......);
                    flag.set(false);
                } catch (Exception e) {
                    log.error("[历史数据回放] - 数据推送异常, 数据: [{}].", message, e);
                }
            } catch (Exception e) {
                log.error("Error executing scheduled task", e);
            }
        }, 0, 60, TimeUnit.SECONDS); // 0秒延迟,每60秒执行一次
    }

    /**
     * 处理Redis消息,用于接收并处理来自Redis的订阅消息。
     *
     * @param channel Redis频道
     * @param content 消息内容
     */
    @Override
    public void handleMessage(String channel, String content) {
        flag.set(true);
    }

    /**
     * 群发消息到所有连接的客户端。
     *
     * @param message 要发送的消息
     */
    public static void sendInfo(String message) {
        for (WebSocketServer item : webSocketSet) {
            try {
                item.sendMessage(message);
            } catch (IOException e) {
                log.error("[NVR 数据对接] - 数据推送异常, 数据: [{}].", message, e);
                continue;
            }
        }
    }

    /**
     * 向指定会话的客户端发送消息。
     *
     * @param session 目标会话
     * @param message 要发送的消息
     */
    public static void sendInfo(Session session, String message) {
        for (WebSocketServer item : webSocketSet) {
            try {
                if (null != session && item.session.equals(session)) {
                    item.sendMessage(message);
                }
            } catch (IOException e) {
                log.error("[数据对接] - 数据推送异常, 数据: [{}].", message, e);
                continue;
            }
        }
    }

    /**
     * 发送消息到客户端。
     *
     * @param message 要发送的消息内容
     * @throws IOException 如果发送失败
     */
    public void sendMessage(String message) throws IOException {
        this.session.getBasicRemote().sendText(message);
    }

}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值