Springboot整合Canal -- Canal 多客户端

本文介绍了如何在SpringBoot项目中使用自定义注解和注册器,批量定义和管理Canal客户端,以便简化配置并批量获取MySQL数据变动。通过`CanalConfig`注解和`CanalConnectorRegistry`,实现了按需生成Canal连接器实例,以及消息处理的示例。
摘要由CSDN通过智能技术生成


前言

在项目中如果想要多个Canal 客户端通过tcp直连接入Canal 服务端,显然需要定义多个连接不同实例的客户端,而每个客户端除了连接到的实例不同其它配置几乎都相同,如果定义多个客户端显然会造成很多重复代码,那么spring 中有什么办法可以批量定义canal客户端?


一、批量注册bean定义:

我们知道spring中bean 的生成是依靠bean 定义,所以如果我们可以批量定义canal客户端 BeanDefinition ,然后将其注册到spring ,这样spring 就可以来生成我们需要的bean。而在spring 中我们可以 使用ImportBeanDefinitionRegistrar来自定义bean;

1.1 定义Canal注解:

CanalConfig.java

import org.springframework.context.annotation.Import;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Import(CanalConnectorRegistry.class)
public @interface CanalConfig {
	// 定义需要连接的canal 实例数组
    String[] destinations() default "";
}

然后在 spring 启动类 就可以增加改注解:

import com.example.spring_canal.config.CanalConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@CanalConfig(destinations = {"test","aabbcc"})
public class SpringCanalApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringCanalApplication.class, args);
    }

}

1.2 canal bean定义注册:

CanalConnectorRegistry.java

import org.springframework.beans.MutablePropertyValues;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanNameGenerator;
import org.springframework.beans.factory.support.GenericBeanDefinition;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.type.AnnotationMetadata;

import java.util.Map;


public class CanalConnectorRegistry implements ImportBeanDefinitionRegistrar {

    @Override
    public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry, BeanNameGenerator importBeanNameGenerator) {
    	// 获取CanalConfig  的注解内容
        Map<String, Object> annotationAttributes = importingClassMetadata.getAnnotationAttributes(CanalConfig.class.getName());
        // 获取destinations 要连接的实例
        String[] destinations = (String[]) annotationAttributes.get("destinations");

        for (int i = 0; i < destinations.length; i++) {
            GenericBeanDefinition beanDefinition = new GenericBeanDefinition();
            // 为CanalConnectorFactory 设置 destinationRegistry属性参数
            MutablePropertyValues properties = new MutablePropertyValues();
            properties.add("destinationRegistry", destinations[i]);
            beanDefinition.setPropertyValues(properties);
            // 定义 使用CanalConnectorFactory 来生成bean 对象
            beanDefinition.setBeanClass(CanalConnectorFactory.class);
            // 因为要生成的canalConnector bean对象都是CanalConnector 类型所以bean 的名称不能重复
            // 本文生成bean 的名称为canalConnector0,canalConnector1,,,
            registry.registerBeanDefinition("canalConnector" + i, beanDefinition);
        }


    }

    @Override
    public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
        ImportBeanDefinitionRegistrar.super.registerBeanDefinitions(importingClassMetadata, registry);
    }
}

1.3 canal bean 生成:

CanalConnectorFactory.java

import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import java.net.InetSocketAddress;

@Component
public class CanalConnectorFactory implements FactoryBean {

    private String  destinationRegistry;
	// 定义canal 服务段的地址和端口
    @Value("${canal.server.host}")
    private String canalServerHost;

    @Value("${canal.server.port}")
    private int canalServerPort;

    public void setDestinationRegistry(String destinationRegistry) {
        this.destinationRegistry = destinationRegistry;
    }

    public CanalConnector createConnector(String destination, String username, String password) {
        return CanalConnectors.newSingleConnector(
                new InetSocketAddress(canalServerHost, canalServerPort),
                destination, username, password
        );
    }

    public CanalConnector createConnector(String destination) {
        return CanalConnectors.newSingleConnector(
                new InetSocketAddress(canalServerHost, canalServerPort),
                destination, "", ""
        );
    }

    @Override
    public Object getObject() throws Exception {
    	// 生成 canal 客户端的bean
        return createConnector(destinationRegistry);
    }

    @Override
    public Class<?> getObjectType() {
        return CanalConnector.class;
    }
}

canal 服务端ip 端口定义:

canal.server.host=localhost
canal.server.port=11111

这样当在项目中 去获取 canalConnector0,canalConnector1,,,这样的bean 时就会通过 CanalConnectorFactory 的getObject() 去生成bean;

二、canal客户端获取mysql数据变动

2.1 canal客户端

CanalService2.java


import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Component
// 根据canal.enable 属性值取加载 这个bean 如果 canal.enable 为false 则不加载bean
@ConditionalOnProperty(name = "canal.enable", havingValue = "true")
public class CanalService2 implements DisposableBean {

    @Autowired
    private ApplicationContext context;
    @Autowired
    private RedisTemplate redisTemplate;

	// 定义要连接的实例数组,
	// 注意顺序和 @CanalConfig(destinations = {"test","aabbcc"}) 保持一致
    @Value("#{'${canal.destination.values}'.split(',')}")
    private List<String> destinations;
    // 定义每个实例中要监听的表,注意顺序和canal.destination.values 保持一致
    @Value("#{'${canal.client.subscribe.filters}'.split(',')}")
    private List<String> canalFilters;

    // 定义每个实例中每次要获取表动条数,注意顺序和canal.destination.values 保持一致
    @Value("#{'${canal.client.batch.sizes}'.split(',')}")
    private List<Integer> batchSizes;


    @Autowired
    private CanalListener canalListener;

	
    private List<CanalConnector> connectors = new ArrayList<>(1 << 3);


    @PostConstruct
    public void run() {
    	// 开启线程进行数据消费
        for (int i = 0; i < destinations.size(); i++) {
            int finalI = i;
            new Thread(() -> toConsumeMessage(finalI, destinations.get(finalI))).start();
        }


    }

    private void toConsumeMessage(int i, String destination) {
    	// 获取spring 容器中的 CanalConnector bean
        CanalConnector canalConnector = (CanalConnector) context.getBean("canalConnector" + i);
        // 收集项目中使用到的CanalConnector  bean 便于后续项目终止进行端口连接使用
        connectors.add(canalConnector);
        // 定义最后消费的位点
        long lastOffset = fetchFromPosition(canalConnector, i, destination);


        while (true) {
        	// 获取消息,并且不进行ack 确认
            Message message = canalConnector.getWithoutAck(batchSizes.get(i));
            long batchId = message.getId();
            List<CanalEntry.Entry> entryList = message.getEntries();
            int size = message.getEntries().size();
            // 如果没有获取到消息则2s 后在次进行获取
            if (batchId == -1 || entryList.isEmpty()) {
                try {
                    // 线程休眠2秒
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                continue;
            }
            // 比对消费位点,如果项目中已经消费过该条数据则继续进行下一次数据拉取
            long nowOffset = entryList.get(0).getHeader().getLogfileOffset();
            if (nowOffset <= lastOffset) {
                continue;
            }
            try {
            	// 消费数据
                canalListener.onMessage(message);
                // 向服务端提交ack 确认
                canalConnector.ack(batchId);
                // 保存最后消费的位点,防止项目重启后 重复消费消息
                lastOffset = message.getEntries().get(size - 1).getHeader().getLogfileOffset();
                savePositionState(lastOffset, destination);
            } catch (Exception ex) {
                log.error("consume error:{}", ex.getMessage());
                // 回滚到未进行 ack 的地方,指定回滚具体的batchId
                canalConnector.rollback(batchId);
            }

        }
    }

    // 获取并设置消费的起始位点
    private long fetchFromPosition(CanalConnector canalConnector, int i, String key) {
        // Canal 连接器连接
        canalConnector.connect();
        // 订阅数据变更
        canalConnector.subscribe(canalFilters.get(i));
        // 回滚到未进行 ack 的地方,下次fetch的时候,可以从最后一个没有 ack 的地方开始拿
        canalConnector.rollback();
        // 从存储中获取上次消费的位点
        long position = getPositionState(key);
        return position;
    }

    // 获取位点状态
    private long getPositionState(String key) {
        // TODO: 从存储中获取上次消费的位点
        Object slot = redisTemplate.opsForValue().get("canal:" + key);
        if (null != slot) {
            if (slot instanceof Long) {
                return (long) slot;
            } else {
                return ((Integer) slot).longValue();
            }

        }
        return -1;
    }

    // 保存位点状态
    private void savePositionState(long position, String key) {
        // TODO: 将 position 保存到存储中
        redisTemplate.opsForValue().set("canal:" + key, position);
    }


    @Override
    public void destroy() throws Exception {
    	// 项目关闭断开连接
        if (null != connectors && !CollectionUtils.isEmpty(connectors)) {
            connectors.stream().forEach(oneConnect -> {
                if (null != oneConnect) {
                    oneConnect.disconnect();
                }
            });
        }

    }
}

参数配置:

canal.enable=true
canal.destination.values=test,aabbcc
canal.client.subscribe.filters=test.test_user|test.user,biglog.about_us
canal.client.batch.sizes=10,10

2.2 消息处理

CanalListener.java

public interface CanalListener {
    void onMessage(Message msg);
}

MyCanalListener.java



import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import com.google.protobuf.InvalidProtocolBufferException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@Component
public class MyCanalListener implements CanalListener {

    @Override
    public void onMessage(Message msg) {
        List<CanalEntry.Entry> entries = msg.getEntries();

        for (CanalEntry.Entry entry : entries) {
            if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
                CanalEntry.RowChange rowChange = null;
                try {
                    rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                } catch (InvalidProtocolBufferException e) {
                    throw new RuntimeException("parse error", e);
                }
                String tableName = entry.getHeader().getTableName();
                CanalEntry.EventType eventType = rowChange.getEventType();
                List<CanalEntry.RowData> rowDataList = rowChange.getRowDatasList();
                String schemaName = entry.getHeader().getSchemaName();
                // 处理数据变更事件
                for (CanalEntry.RowData rowData : rowDataList) {
                    switch (eventType) {
                        case INSERT:
                            // 处理插入事件
                            dealInsert(schemaName, tableName, rowData.getAfterColumnsList());
                            break;
                        case UPDATE:
                            // 处理更新事件
                            dealUpdate(schemaName, tableName, rowData.getAfterColumnsList());
                            break;
                        case DELETE:
                            // 处理删除事件
                            dealDelate(schemaName, tableName, rowData.getBeforeColumnsList());
                            break;
                        default:
                            break;
                    }
                }
            }
        }
    }

    private void dealDelate(String schemaName, String tableName, List<CanalEntry.Column> afterColumnsList) {
        Map<String, Object> dataMap = new HashMap<>();
        for (CanalEntry.Column column : afterColumnsList) {
            dataMap.put(column.getName(), column.getValue());
        }
//        log.debug("delate data:{}", afterColumnsList);
        log.debug("delate map data:{}", dataMap);
    }

    private void dealUpdate(String schemaName, String tableName, List<CanalEntry.Column> columns) {
        Map<String, Object> dataMap = new HashMap<>();
        for (CanalEntry.Column column : columns) {
            dataMap.put(column.getName(), column.getValue());
        }
//        log.debug("update data:{}", columns);
        log.debug("update map data:{}", dataMap);

    }

    private void dealInsert(String schemaName, String tableName, List<CanalEntry.Column> columns) {
        Map<String, Object> dataMap = new HashMap<>();
        for (CanalEntry.Column column : columns) {
            dataMap.put(column.getName(), column.getValue());
        }
//        log.debug("insert data:{}", columns);
        log.debug("insert map data:{}", dataMap);
    }
}


总结

本文通过ImportBeanDefinitionRegistrar 进行canal客户端bean 定义的注册,通过FactoryBean ,注意canal 客户端的默认的id 为1001,目前canal server上的一个instance只能有一个client消费。

参考

Canal ClientAPI 参考;

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值