Canal实时同步发送JSON数据示例代码

使用 Canal 需要安装Canal相关组件,安装步骤参阅:

Windows下Canal.admin-1.1.6安装部署 和 Windows下Canal.deployer-1.1.6安装部署

因合作需要,库中大半表数据需要实时同步给合作方,他们自己做分析处理用!

同时要求去除敏感数据和过滤一些不完整数据,最后决定用Canal来实时同步发送JSON数据给合作方,如下为示例代码:

主体接口代码:CanalClientService.java

import com.alibaba.otter.canal.protocol.CanalEntry;

/**
 * Canal客户端服务接口
 *
 * @author songjianyong
 */
public interface CanalClientService extends Runnable {

    /**
     * 启动binlog日志监听客户端
     */
    void startSlave();

    /**
     * 关闭binlog日志监听客户端
     */
    void stopSlave();

    /**
     * 是否忽略库或表的同步
     *
     * @param entryHeader 条目标题
     * @return boolean
     */
    boolean ignoreTableSchemaOrName(CanalEntry.Header entryHeader);
}

主体接口代码实现类:CanalClientServiceImpl.java

import cn.hutool.crypto.Mode;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.core.toolkit.TableInfoHelper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.DateUtils;
import org.reflections.Reflections;
import org.reflections.util.ConfigurationBuilder;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.util.ReflectionUtils;

import javax.annotation.Resource;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.URI;
import java.text.ParseException;
import java.util.*;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * Canal客户端服务接口实现类
 *
 * @author songjianyong
 */
@Slf4j
@Component
public class CanalClientServiceImpl implements CanalClientService, CommandLineRunner {
    /**
     * 是否关闭binlog日志监听客户端
     */
    private volatile boolean stop;
    /**
     * 存放表和实体类的一一对应关系
     */
    private final Map/*实体类*/> allTableEntity = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);

    private final ThreadFactory namedThreadFactory = new ThreadFactoryBuilder()
            .setNameFormat("canal-client-%d").build();
    private final ThreadPoolExecutor singleThreadPool = new ThreadPoolExecutor(1, 1,
            0L, TimeUnit.MILLISECONDS,
            new LinkedBlockingQueue<>(1), namedThreadFactory, new ThreadPoolExecutor.DiscardPolicy());

    @Resource
    private CanalProperties canalProperties;
    @Resource
    private ObjectMapper objectMapper;
    @Resource
    private CanalFeignClient canalFeignClient;

    @Override
    public void run() {

        if (!Objects.equals(true, canalProperties.getEnableCanal())) {
            log.warn("未开启Canal同步");
            return;
        }

        if (!checkCanalProperties()) {
            return;
        }

        stop = false;
        SocketAddress address = new InetSocketAddress(canalProperties.getHostName(), canalProperties.getPort());
        CanalConnector connector = CanalConnectors.newSingleConnector(address, canalProperties.getDestination(), canalProperties.getUsername(), canalProperties.getPassword());
        int batchSize = (Objects.isNull(canalProperties.getBatchSize()) || canalProperties.getBatchSize() <= 0) ? 1000 : canalProperties.getBatchSize();
        long emptyCount = 0;
        long batchId = -1;

        try {
            connector.connect();
            connector.subscribe(".*\\..*");
            connector.rollback();

            log.warn("Canal同步已启动");
            while (!stop) {
                // 获取指定数量的数据
                Message message = connector.getWithoutAck(batchSize);
                batchId = message.getId();
                int size = message.getEntries().size();
                if (batchId == -1 || size == 0) {
                    emptyCount++;
                    if (Objects.equals(emptyCount % 60L, 0L)) {
                        log.warn("空闲 次数 : {}", emptyCount);
                    }
                    try {
                        TimeUnit.SECONDS.sleep(1L);
                    } catch (InterruptedException e) {
                        log.error(e.getMessage(), e);
                    }
                } else {
                    emptyCount = 0;
                    log.info("message[batchId={},size={}]", batchId, size);
                    List values = parseEntry(message.getEntries());
                    deliverData(values);
                }

                // 提交确认
                connector.ack(batchId);
            }
        } catch (Exception e) {
            // 处理失败, 回滚数据
            connector.rollback(batchId);
            log.warn("Canal同步异常已停止");
            connector.disconnect();
        } finally {
            log.warn("Canal同步已停止");
            connector.disconnect();
        }
    }

    /**
     * 传送数据至合作方
     *
     * @param values 值
     */
    void deliverData(List values) throws JsonProcessingException {
        for (String deliverUrl : canalProperties.getDeliverUrls()) {
            URI uri = URI.create(deliverUrl);
            ResponseVO response = canalFeignClient.deliverData(uri, canalProperties.getReferer(), values);
            Object[] args = {deliverUrl, System.lineSeparator(),
                    objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(response)};
            log.info("Canal数据传递至[{}]结果:{}{}", args);
        }
    }


    /**
     * 解析条目数据
     *
     * @param entries 条目
     * @return {@link List}<{@link Object}>
     */
    private List parseEntry(List entries) throws JsonProcessingException, InstantiationException, IllegalAccessException {
        List list = new ArrayList<>(entries.size());
        for (CanalEntry.Entry entry : entries) {
            if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONBEGIN || entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONEND) {
                continue;
            }

            CanalEntry.RowChange rowChange;
            try {
                rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
            } catch (Exception e) {
                throw new RuntimeException("ERROR ## parser of eromanga-event has an error , data:" + entry, e);
            }

            CanalEntry.EventType eventType = rowChange.getEventType();
            CanalEntry.Header entryHeader = entry.getHeader();
            if (ignoreTableSchemaOrName(entryHeader)) {
                continue;
            }

            Object[] args = {entryHeader.getLogfileName(), entryHeader.getLogfileOffset(),
                    entryHeader.getSchemaName(), entryHeader.getTableName(), eventType};
            log.info("监听到binlog[{}:{}] , 表[{}.{}] , 事件 : {}", args);

            Class clazz = allTableEntity.get(entryHeader.getTableName());
            if (Objects.isNull(clazz)) {
                log.warn("表对应实体类不存在:{}.{}", entryHeader.getSchemaName(), entryHeader.getTableName());
                continue;
            }
            log.info("库表对应的实体类:[{}.{}] -> [{}]", entryHeader.getSchemaName(), entryHeader.getTableName(), clazz.getName());

            buildData(list, rowChange, clazz);
        }

        return list;
    }

    /**
     * 构建数据:JSON
     *
     * @param list      数据集合
     * @param rowChange 变化记录
     * @param clazz     clazz
     * @throws InstantiationException  实例化异常
     * @throws IllegalAccessException  非法访问异常
     * @throws JsonProcessingException json处理异常
     */
    private void buildData(List list, CanalEntry.RowChange rowChange, Class clazz) throws InstantiationException, IllegalAccessException, JsonProcessingException {
        CanalEntry.EventType eventType = rowChange.getEventType();
        for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
            if (Objects.equals(eventType, CanalEntry.EventType.DELETE)) {
                Object value = newInstance(clazz, rowData.getBeforeColumnsList());
                add(list, value);
                continue;
            }
            if (Objects.equals(eventType, CanalEntry.EventType.INSERT)) {
                Object value = newInstance(clazz, rowData.getAfterColumnsList());
                add(list, value);
                continue;
            }
            if (Objects.equals(eventType, CanalEntry.EventType.UPDATE)) {
                List afterColumnsList = rowData.getAfterColumnsList();
                Object value = newInstance(clazz, afterColumnsList);
                add(list, value);
            }
        }
    }

    /**
     * 添加:忽略敏感数据
     *
     * @param list  列表
     * @param value 数据
     */
    void add(List list, Object value) throws JsonProcessingException {
        if (Objects.isNull(value)) {
            return;
        }

        // 数据敏感不添加到发送集合中,直接返回
        if(sensitive(value)){
            return;
        }

        String json = objectMapper.writeValueAsString(value);

        // 数据加密处理
        String token = UUID.randomUUID().toString();
        CanalDeliverData canalDeliverData = new CanalDeliverData();
        canalDeliverData.setDataType(CanalDataTypeEnum.JSON.name());
        canalDeliverData.setToken(AesUtils.encryptHex(token, Mode.CBC));
        canalDeliverData.setData(AesUtils.encryptHex(json, Mode.CBC));

        list.add(canalDeliverData);
    }

    /**
     * 反射方式构建实例对象:此处利用了mybatis-plus开源组件中表和实体类一一对应关系,保证数据字段的一致性
     *
     * @param clazz   表对应实体类
     * @param columns 列数据
     * @return {@link Object}
     */
    private Object newInstance(Class clazz, List columns) throws InstantiationException, IllegalAccessException {
        TableInfo tableInfo = TableInfoHelper.getTableInfo(clazz);
        List fieldList = tableInfo.getFieldList();
        Object value = clazz.newInstance();
        long countKey = columns.stream().filter(CanalEntry.Column::getIsKey).count();
        if (!Objects.equals(1L, countKey)) {
            log.error("表主键数量必须是1才能同步:[{}:{}]", tableInfo.getTableName(), countKey);
            return null;
        }
        for (CanalEntry.Column column : columns) {

            if (!column.hasValue()) {
                continue;
            }
            if (column.getIsKey() && StringUtils.isBlank(tableInfo.getKeyProperty())) {
                log.warn("表主键字段无对应类属性:[{}.{}]", tableInfo.getTableName(), column.getName());
                continue;
            }

            if (column.getIsKey() && StringUtils.isNotBlank(tableInfo.getKeyProperty())) {
                // 设置主键属性值
                setValue(value, tableInfo.getKeyProperty(), column, clazz, tableInfo);
                continue;
            }

            TableFieldInfo tableFieldInfo = fieldList.stream()
                    .filter(e -> StringUtils.equalsIgnoreCase(e.getColumn(), column.getName()))
                    .findFirst()
                    .orElse(null);

            if (Objects.isNull(tableFieldInfo)) {
                log.warn("表字段无对应类属性:[{}.{}]", tableInfo.getTableName(), column.getName());
                continue;
            }

            String property = tableFieldInfo.getProperty();
            // 设置非主键属性值
            setValue(value, property, column, clazz, tableInfo);
        }
        return value;
    }

    /**
     * 反射方式设置对象属性值
     *
     * @param target    目标对象
     * @param property  目标对象属性
     * @param column    列数据
     * @param clazz     目标对象类型
     * @param tableInfo 表信息
     */
    void setValue(Object target, String property, CanalEntry.Column column, Class clazz, TableInfo tableInfo) {
        Field field = ReflectionUtils.findField(clazz, property);
        if (Objects.isNull(field)) {
            log.warn("类无此属性:{}.{}", clazz.getName(), property);
            return;
        }
        String methodName = String.format("set%s", StringUtils.capitalize(property));
        Method method = ReflectionUtils.findMethod(clazz, methodName, field.getType());
        if (Objects.isNull(method)) {
            log.warn("表字段无映射方法:{}.{}", tableInfo.getTableName(), column.getName());
            return;
        }
        ReflectionUtils.invokeMethod(method, target, convert(field.getType(), column.getValue()));
    }

    /**
     * 转换值
     *
     * @param type  值类型
     * @param value 值
     * @return {@link Object}
     */
    Object convert(Class type, String value) {

        if (Objects.equals(type, String.class)) {
            return value;
        }

        if (Objects.equals(type, Integer.class) || Objects.equals(type, int.class)) {
            return Integer.parseInt(value);
        }

        if (Objects.equals(type, Long.class) || Objects.equals(type, long.class)) {
            return Long.parseLong(value);
        }

        if (Objects.equals(type, Date.class)) {
            String[] parsePatterns = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd"};
            try {
                return DateUtils.parseDate(value, parsePatterns);
            } catch (ParseException e) {
                log.error(e.getMessage(), e);
            }
        }

        throw new RuntimeException(String.format("未处理类型:%s", type.getName()));
    }

    @Override
    public void startSlave() {
        if (singleThreadPool.getPoolSize() >= singleThreadPool.getCorePoolSize()) {
            log.warn("Canal binlog 日志监听运行中,本次启动忽略!");
            return;
        }
        singleThreadPool.execute(this);
    }

    @Override
    public void stopSlave() {
        stop = true;
    }

    /**
     * 构建表和实体类的一一对应关系
     */
    private void allTableEntity() {
        if (!allTableEntity.isEmpty()) {
            return;
        }
        Reflections reflections = new Reflections(new ConfigurationBuilder().forPackage("com.coocaa"));
        Set> classes = reflections.getTypesAnnotatedWith(TableName.class);
        for (Class clazz : classes) {
            TableName annotation = clazz.getAnnotation(TableName.class);
            if (Objects.isNull(annotation)) {
                continue;
            }
            allTableEntity.put(annotation.value(), clazz);
        }
    }

    @Override
    public boolean ignoreTableSchemaOrName(CanalEntry.Header entryHeader) {
        String[] includeTableSchemas = canalProperties.getIncludeTableSchemas();
        if (ArrayUtils.isNotEmpty(includeTableSchemas)
                && Arrays.stream(includeTableSchemas).noneMatch(e -> StringUtils.equalsIgnoreCase(e, entryHeader.getSchemaName()))) {
            return true;
        }

        String[] excludeTableNames = canalProperties.getExcludeTableNames();
        if (ArrayUtils.isEmpty(excludeTableNames)) {
            return false;
        }

        for (String excludeTableName : excludeTableNames) {
            String[] tableSchemaOrName = StringUtils.split(excludeTableName, ',');
            if (tableSchemaOrName.length > 2) {
                log.error("表配置格式错误:{}", excludeTableName);
                return false;
            }
            if (Objects.equals(1, tableSchemaOrName.length)
                    && StringUtils.equalsIgnoreCase(excludeTableName, entryHeader.getTableName())) {
                log.warn("表的数据同步已忽略:{}", excludeTableName);
                return true;
            }

            if (Objects.equals(2, tableSchemaOrName.length)
                    && StringUtils.equalsIgnoreCase(tableSchemaOrName[0], entryHeader.getSchemaName())
                    && StringUtils.equalsIgnoreCase(tableSchemaOrName[1], entryHeader.getTableName())) {
                log.warn("库表的数据同步已忽略:{}.{}", tableSchemaOrName[0], tableSchemaOrName[1]);
                return true;
            }

        }

        return false;
    }

    /**
     * 检查Canal配置
     */
    boolean checkCanalProperties() {
        if (StringUtils.isBlank(canalProperties.getHostName())) {
            log.error("Canal 服务端主机地址未配置");
            return false;
        }

        if (Objects.isNull(canalProperties.getPort())) {
            log.error("Canal 服务端主机端口未配置");
            return false;
        }

        if (StringUtils.isBlank(canalProperties.getDestination())) {
            log.error("Canal 创建单链接的客户端链接目的地未配置");
            return false;
        }

        if (StringUtils.isBlank(canalProperties.getUsername())) {
            log.error("Canal MySQL slave 账号未配置");
            return false;
        }

        if (ArrayUtils.isEmpty(canalProperties.getDeliverUrls())) {
            log.error("Canal 数据投递地址未配置");
            return false;
        }

        if (StringUtils.isBlank(canalProperties.getReferer())) {
            log.error("Canal 数据投递来源地址未配置");
            return false;
        }

        return true;
    }

    @Override
    public void run(String... args) throws Exception {
        // 项目启动时自动开启Canal同步
        startSlave();
        allTableEntity();
    }
}

辅助配置类:CanalProperties.java

import com.alibaba.nacos.api.config.ConfigType;
import com.alibaba.nacos.api.config.annotation.NacosConfigurationProperties;
import lombok.Data;
import org.springframework.context.annotation.Configuration;

/**
 * Canal同步配置属性类
 *
 * @author songjianyong
 */
@Data
@Configuration
@NacosConfigurationProperties(groupId = "groupId", dataId = "dataId", prefix = "canal", autoRefreshed = true, type = ConfigType.YAML)
public class CanalProperties {
    /**
     * 是否开启Canal同步
     */
    private Boolean enableCanal;
    /**
     * the Host name
     */
    private String hostName;
    /**
     * The port number,如:11111
     */
    private Integer port;
    /**
     * 创建单链接的客户端链接目的地
     */
    private String destination;
    /**
     * MySQL slave 账号
     */
    private String username;
    /**
     * MySQL slave 密码
     */
    private String password;

    /**
     * 获取指定数量的数据,默认1000
     */
    private Integer batchSize;

    /**
     * 指定需要同步的库名
     */
    private String[] includeTableSchemas;
    /**
     * 指定不需要同步的表名,格式:[库名.]表名
     */
    private String[] excludeTableNames;

    /**
     * 数据投递来源地址
     */
    private String referer;
    /**
     * 数据投递地址
     */
    private String[] deliverUrls;
}

其中类方法 CanalClientServiceImpl.newInstance 中利用了mybatis-plus 开源组件中表和实体类一一对应关系,对构建JSON实体类省去大量代码,否则若每个表数据手动构建,一旦新增新的表数据,又要不断累加代码了!

那么一旦数据实时同步过程中出了问题,表中数据同步到哪一行了呢,如何续传呢?因业务的特殊性,这里无需考虑此类问题!

但通常业务来说,数据传送过程中出了问题,一定要修复后重新续传的,那就需要额外提供手段了,此处不做深究!

你可能感兴趣的:(spring,MySQL,java,canal,mysql)