在Java程序中监听mysql的binlog

打印 上一主题 下一主题

主题 901|帖子 901|积分 2703

目次

1、背景

最近在开辟的过程中遇到这么一个问题,当产生某种范例的工单后,需要实时通知到另外的体系,由另外的体系举行数据的研判操作。  由于某种缘故原由, 像向消息队列中推送工单消息、或直接调用另外体系的接口、或者部署Cannal 等都不可行,因此此处利用 mysql-binlog-connector-java 这个库来完成数据库binlog的监听,从而通知到另外的体系。
2、mysql-binlog-connector-java简介

mysql-binlog-connector-java是一个Java库,通过它可以实现mysql binlog日志的监听和剖析操作。它提供了一系列可靠的方法,使开辟者通过监听数据库的binlog日志,来实时获取数据库的变更信息,比如:数据的插入、更新、删除等操作。
github地点 https://github.com/osheroff/mysql-binlog-connector-java
3、准备工作

1、验证数据库是否开启binlog
  1. mysql> show variables like '%log_bin%';
  2. +---------------------------------+------------------------------------+
  3. | Variable_name                   | Value                              |
  4. +---------------------------------+------------------------------------+
  5. | log_bin                         | ON                                 |
  6. | log_bin_basename                | /usr/local/mysql/data/binlog       |
  7. | log_bin_index                   | /usr/local/mysql/data/binlog.index |
  8. | log_bin_trust_function_creators | OFF                                |
  9. | log_bin_use_v1_row_events       | OFF                                |
  10. | sql_log_bin                     | ON                                 |
  11. +---------------------------------+------------------------------------+
复制代码
log_bin 的值为 ON 时,表现开启了binlog
2、开启数据库的binlog
  1. # 修改 my.cnf 配置文件
  2. [mysqld]
  3. #binlog日志的基本文件名,需要注意的是启动mysql的用户需要对这个目录(/usr/local/var/mysql/binlog)有写入的权限
  4. log_bin=/usr/local/var/mysql/binlog/mysql-bin
  5. # 配置binlog日志的格式
  6. binlog_format = ROW
  7. # 配置 MySQL replaction 需要定义,不能和已有的slaveId 重复
  8. server-id=1
复制代码
3、创建具有REPLICATION SLAVE权限的用户
  1. CREATE USER binlog_user IDENTIFIED BY 'binlog#Replication2024!';  
  2. GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'binlog_user'@'%';
  3. FLUSH PRIVILEGES;
复制代码

4、事件范例 eventType 解释

注意:不同的mysql版本事件范例可能不同,我们当地是mysql8
  1. TABLE_MAP: 在表的 insert、update、delete 前的事件,用于记录操作的数据库名和表名。
  2. EXT_WRITE_ROWS: 插入数据事件类型,即 insert 类型
  3. EXT_UPDATE_ROWS: 插入数据事件类型,即 update 类型
  4. EXT_DELETE_ROWS: 插入数据事件类型,即 delete 类型
  5. ROTATE: 当mysqld切换到新的二进制日志文件时写入。当发出一个FLUSH LOGS 语句。或者当前二进制日志文件超过max_binlog_size。
复制代码
1、TABLE_MAP 的注意事项

一般情况下,当我们向数据库中实行insert、update或delete事件时,一般会先有一个TABLE_MAP事件发出,通过这个事件,我们就知道当前操作的是那个数据库和表。 但是如果我们操作的表上存在触发器时,那么可能次序就会错乱,导致我们获取到错误的数据库名和表名。

2、获取操作的列名

此处以 EXT_UPDATE_ROWS 事件为列,当我们往数据库中update一条记录时,触发此事件,事件内容为:
  1. Event{header=EventHeaderV4{timestamp=1727498351000, eventType=EXT_UPDATE_ROWS, serverId=1, headerLength=19, dataLength=201, nextPosition=785678, flags=0}, data=UpdateRowsEventData{tableId=264, includedColumnsBeforeUpdate={0, 1, 2, 3, 4, 5, 6, 7}, includedColumns={0, 1, 2, 3, 4, 5, 6, 7}, rows=[
  2.     {before=[1, zhangsan, 张三-update, 0, [B@7b720427, [B@238552f, 1727524798000, 1727495998000], after=[1, zhangsan, 张三-update, 0, [B@21dae489, [B@2c0fff72, 1727527151000, 1727498351000]}
  3. ]}}
复制代码
从上面的语句中可以看到includedColumnsBeforeUpdate和includedColumns这2个字段表现更新前的列名和更新后的列名,但是这个时候展示的数字,那么如果展示详细的列名呢? 可以通过information_schema.COLUMNS获取。

5、监听binlog的position

1、从最新的binlog位置开始监听

默认情况下,就是从最新的binlog位置开始监听。
  1. BinaryLogClient client = new BinaryLogClient(hostname, port, username, password);
复制代码
2、从指定的位置开始监听
  1. BinaryLogClient client = new BinaryLogClient(hostname, port, username, password);
  2. // binlog的文件名
  3. client.setBinlogFilename("");
  4. // binlog的具体位置
  5. client.setBinlogPosition(11);
复制代码
3、断点续传

这个指的是,当我们的 mysql-binlog-connector-java 程序宕机后,如果数据发生了binlog的变更,我们应该从程序前次宕机的位置的position举行监听,而不是程序重启后从最新的binlog position位置开始监听。默认情况下mysql-binlog-connector-java程序没有为我们实现,需要我们自己去实现。大概的实现思路为:

  • 监听 ROTATE事件,可以获取到最新的binlog文件名和位置。
  • 记录每个事件的position的位置。
6、创建表和准备测试数据
  1. CREATE TABLE `binlog_demo`
  2. (
  3.     `id`          int NOT NULL AUTO_INCREMENT COMMENT '主键',
  4.     `user_name`   varchar(64) DEFAULT NULL COMMENT '用户名',
  5.     `nick_name`   varchar(64) DEFAULT NULL COMMENT '昵称',
  6.     `sex`         tinyint     DEFAULT NULL COMMENT '性别 0-女 1-男 2-未知',
  7.     `address`     text COMMENT '地址',
  8.     `ext_info`    json        DEFAULT NULL COMMENT '扩展信息',
  9.     `create_time` datetime    DEFAULT NULL COMMENT '创建时间',
  10.     `update_time` timestamp NULL DEFAULT NULL COMMENT '修改时间',
  11.     PRIMARY KEY (`id`),
  12.     UNIQUE KEY `uidx_username` (`user_name`)
  13. ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测试binlog'
  14. -- 0、删除数据
  15. truncate table binlog_demo;
  16. -- 1、添加数据
  17. insert into binlog_demo(user_name, nick_name, sex, address, ext_info, create_time, update_time)
  18. values ('zhangsan', '张三', 1, '地址', '[
  19.   "aaa",
  20.   "bbb"
  21. ]', now(), now());
  22. -- 2、修改数据
  23. update binlog_demo
  24. set nick_name   = '张三-update',
  25.     sex         = 0,
  26.     address     = '地址-update',
  27.     ext_info    = '{
  28.       "ext_info": "扩展信息"
  29.     }',
  30.     create_time = now(),
  31.     update_time = now()
  32. where user_name = 'zhangsan';
  33. -- 3、删除数据
  34. delete
  35. from binlog_demo
  36. where user_name = 'zhangsan';
复制代码
4、功能实现

通过mysql-binlog-connector-java库,当数据库中的表数据发生变更时,举行监听。
1、从最新的binlog位置开始监听

1、引入jar包
  1. <dependencies>
  2.     <dependency>
  3.         <groupId>org.springframework.boot</groupId>
  4.         <artifactId>spring-boot-starter-web</artifactId>
  5.     </dependency>
  6.    
  7.     <dependency>
  8.         <groupId>com.zendesk</groupId>
  9.         <artifactId>mysql-binlog-connector-java</artifactId>
  10.         <version>0.29.2</version>
  11.     </dependency>
  12. </dependencies>
复制代码
2、监听binlog数据
  1. package com.huan.binlog;
  2. import com.github.shyiko.mysql.binlog.BinaryLogClient;
  3. import com.github.shyiko.mysql.binlog.event.Event;
  4. import com.github.shyiko.mysql.binlog.event.EventType;
  5. import com.github.shyiko.mysql.binlog.event.deserialization.EventDeserializer;
  6. import org.slf4j.Logger;
  7. import org.slf4j.LoggerFactory;
  8. import org.springframework.stereotype.Component;
  9. import javax.annotation.PostConstruct;
  10. import javax.annotation.PreDestroy;
  11. import java.io.IOException;
  12. import java.util.concurrent.TimeoutException;
  13. /**
  14. * 初始化 binary log client
  15. *
  16. * @author huan.fu
  17. * @date 2024/9/22 - 16:23
  18. */
  19. @Component
  20. public class BinaryLogClientInit {
  21.     private static final Logger log = LoggerFactory.getLogger(BinaryLogClientInit.class);
  22.     private BinaryLogClient client;
  23.     @PostConstruct
  24.     public void init() throws IOException, TimeoutException {
  25.         /**
  26.          * # 创建用户
  27.          * CREATE USER binlog_user IDENTIFIED BY 'binlog#Replication2024!';
  28.          * GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'binlog_user'@'%';
  29.          * FLUSH PRIVILEGES;
  30.          */
  31.         String hostname = "127.0.0.1";
  32.         int port = 3306;
  33.         String username = "binlog_user";
  34.         String password = "binlog#Replication2024!";
  35.         // 创建 BinaryLogClient客户端
  36.         client = new BinaryLogClient(hostname, port, username, password);
  37.         // 这个 serviceId 不可重复
  38.         client.setServerId(12);
  39.         // 反序列化配置
  40.         EventDeserializer eventDeserializer = new EventDeserializer();
  41.         eventDeserializer.setCompatibilityMode(
  42.                 // 将日期类型的数据反序列化成Long类型
  43.                 EventDeserializer.CompatibilityMode.DATE_AND_TIME_AS_LONG
  44.         );
  45.         client.setEventDeserializer(eventDeserializer);
  46.         client.registerEventListener(new BinaryLogClient.EventListener() {
  47.             @Override
  48.             public void onEvent(Event event) {
  49.                 EventType eventType = event.getHeader().getEventType();
  50.                 log.info("接收到事件类型: {}", eventType);
  51.                 log.warn("接收到的完整事件: {}", event);
  52.                 log.info("============================");
  53.             }
  54.         });
  55.         client.registerLifecycleListener(new BinaryLogClient.AbstractLifecycleListener() {
  56.             @Override
  57.             public void onConnect(BinaryLogClient client) {
  58.                 log.info("客户端连接到 mysql 服务器 client: {}", client);
  59.             }
  60.             @Override
  61.             public void onCommunicationFailure(BinaryLogClient client, Exception ex) {
  62.                 log.info("客户端和 mysql 服务器 通讯失败 client: {}", client);
  63.             }
  64.             @Override
  65.             public void onEventDeserializationFailure(BinaryLogClient client, Exception ex) {
  66.                 log.info("客户端序列化失败 client: {}", client);
  67.             }
  68.             @Override
  69.             public void onDisconnect(BinaryLogClient client) {
  70.                 log.info("客户端断开 mysql 服务器链接 client: {}", client);
  71.             }
  72.         });
  73.         // client.connect 在当前线程中进行解析binlog,会阻塞当前线程
  74.         // client.connect(xxx) 会新开启一个线程,然后在这个线程中解析binlog
  75.         client.connect(10000);
  76.     }
  77.     @PreDestroy
  78.     public void destroy() throws IOException {
  79.         client.disconnect();
  80.     }
  81. }
复制代码
3、测试


从上图中可以看到,我们获取到了更新后的数据,但是详细更新了哪些列名这个我们是不清晰的。
2、获取数据更新详细的列名

此处以更新数据为例,大体的实现思路如下:

  • 通过监听 TABLE_MAP 事件,用于获取到 insert、update或delete语句操作前的数据库和表。
  • 通过查询 information_schema.COLUMNS 表获取 某个表在某个数据库中详细的列信息(比如:列名、列的数据范例等操作)。
2.1 新增common-dbutils依靠用于操作数据库
  1. <dependency>
  2.     <groupId>commons-dbutils</groupId>
  3.     <artifactId>commons-dbutils</artifactId>
  4.     <version>1.8.1</version>
  5. </dependency>
  6. <dependency>
  7.     <groupId>mysql</groupId>
  8.     <artifactId>mysql-connector-java</artifactId>
  9.     <version>8.0.33</version>
  10. </dependency>
复制代码
2.2 监听TABLE_MAP事件,获取数据库和表名


  • 定义2个成员变量,database和tableName用于接收数据库和表名。
  1. /**
  2. * 数据库
  3. */
  4. private String database;
  5. /**
  6. * 表名
  7. */
  8. private String tableName;
复制代码

  • 监听TABLE_MAP事件,获取数据库和表名
  1. // 成员变量 - 数据库名
  2. private String database;
  3. // 成员变量 - 表名
  4. private String tableName;
  5. client.registerEventListener(new BinaryLogClient.EventListener() {
  6.     @Override
  7.     public void onEvent(Event event) {
  8.         EventType eventType = event.getHeader().getEventType();
  9.         log.info("接收到事件类型: {}", eventType);
  10.         log.info("============================");
  11.         if (event.getData() instanceof TableMapEventData) {
  12.             TableMapEventData eventData = (TableMapEventData) event.getData();
  13.             database = eventData.getDatabase();
  14.             tableName = eventData.getTable();
  15.             log.info("获取到的数据库名: {} 和 表名为: {}", database, tableName);
  16.         }
  17.     }
  18. });
复制代码

2.3 编写工具类获取表的列名和位置信息

  1. /**
  2. * 数据库工具类
  3. *
  4. * @author huan.fu
  5. * @date 2024/10/9 - 02:39
  6. */
  7. public class DbUtils {
  8.     public static Map<String, String> retrieveTableColumnInfo(String database, String tableName) throws SQLException {
  9.         Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/temp_work", "binlog_user", "binlog#Replication2024!");
  10.         QueryRunner runner = new QueryRunner();
  11.         Map<String, String> columnInfoMap = runner.query(
  12.                 connection,
  13.                 "select a.COLUMN_NAME,a.ORDINAL_POSITION from information_schema.COLUMNS a where a.TABLE_SCHEMA = ? and a.TABLE_NAME = ?",
  14.                 resultSet -> {
  15.                     Map<String, String> result = new HashMap<>();
  16.                     while (resultSet.next()) {
  17.                         result.put(resultSet.getString("ORDINAL_POSITION"), resultSet.getString("COLUMN_NAME"));
  18.                     }
  19.                     return result;
  20.                 },
  21.                 database,
  22.                 tableName
  23.         );
  24.         connection.close();
  25.         return columnInfoMap;
  26.     }
  27.     public static void main(String[] args) throws SQLException {
  28.         Map<String, String> stringObjectMap = DbUtils.retrieveTableColumnInfo("temp_work", "binlog_demo");
  29.         System.out.println(stringObjectMap);
  30.     }
  31. }
复制代码

2.4 以更新语句为例获取 更新的列名和对应的值

1、编写java代码获取更新后的列和值信息
  1. client.registerEventListener(new BinaryLogClient.EventListener() {
  2.     @Override
  3.     public void onEvent(Event event) {
  4.         EventType eventType = event.getHeader().getEventType();
  5.         log.info("接收到事件类型: {}", eventType);
  6.         log.warn("接收到的完整事件: {}", event);
  7.         log.info("============================");
  8.         // 通过 TableMap 事件获取 数据库名和表名
  9.         if (event.getData() instanceof TableMapEventData) {
  10.             TableMapEventData eventData = (TableMapEventData) event.getData();
  11.             database = eventData.getDatabase();
  12.             tableName = eventData.getTable();
  13.             log.info("获取到的数据库名: {} 和 表名为: {}", database, tableName);
  14.         }
  15.         // 监听更新事件
  16.         if (event.getData() instanceof UpdateRowsEventData) {
  17.             try {
  18.                 // 获取表的列信息
  19.                 Map<String, String> columnInfo = DbUtils.retrieveTableColumnInfo(database, tableName);
  20.                 // 获取更新后的数据
  21.                 UpdateRowsEventData eventData = ((UpdateRowsEventData) event.getData());
  22.                 // 可能更新多行数据
  23.                 List<Map.Entry<Serializable[], Serializable[]>> rows = eventData.getRows();
  24.                 for (Map.Entry<Serializable[], Serializable[]> row : rows) {
  25.                     // 更新前的数据
  26.                     Serializable[] before = row.getKey();
  27.                     // 更新后的数据
  28.                     Serializable[] after = row.getValue();
  29.                     // 保存更新后的一行数据
  30.                     Map<String, Serializable> afterUpdateRowMap = new HashMap<>();
  31.                     for (int i = 0; i < after.length; i++) {
  32.                         // 因为 columnInfo 中的列名的位置是从1开始,而此处是从0开始
  33.                         afterUpdateRowMap.put(columnInfo.get((i + 1) + ""), after[i]);
  34.                     }
  35.                     log.info("监听到更新的数据为: {}", afterUpdateRowMap);
  36.                 }
  37.             } catch (Exception e) {
  38.                 log.error("监听更新事件发生了异常");
  39.             }
  40.         }
  41.         // 监听插入事件
  42.         if (event.getData() instanceof WriteRowsEventData) {
  43.             log.info("监听到插入事件");
  44.         }
  45.         // 监听删除事件
  46.         if (event.getData() instanceof DeleteRowsEventData) {
  47.             log.info("监听到删除事件");
  48.         }
  49.     }
  50. });
复制代码
2、实行更新语句
  1. update binlog_demo
  2.     set nick_name = '张三-update11',
  3.         -- sex = 0,
  4.         -- address = '地址-update1',
  5.         -- ext_info = '{"ext_info":"扩展信息"}',
  6.         -- create_time = now(),
  7.         update_time = now()
  8. where user_name = 'zhangsan';
复制代码
3、查看监听到更新数据信息


3、自定义序列化字段

从下图中可知,针对 text 范例的字段,默认转换成了byte[]范例,那么怎样将其转换成String范例呢?
此处针对更新语句来演示

3.1 自定义更新数据text范例字段的反序列

注意:断点跟踪源码发现text范例的数据映射成了blob范例,因此需要重写 deserializeBlob 方法
  1. public class CustomUpdateRowsEventDataDeserializer extends UpdateRowsEventDataDeserializer {
  2.     public CustomUpdateRowsEventDataDeserializer(Map<Long, TableMapEventData> tableMapEventByTableId) {
  3.         super(tableMapEventByTableId);
  4.     }
  5.     @Override
  6.     protected Serializable deserializeBlob(int meta, ByteArrayInputStream inputStream) throws IOException {
  7.         byte[] bytes = (byte[]) super.deserializeBlob(meta, inputStream);
  8.         if (null != bytes && bytes.length > 0) {
  9.             return new String(bytes, StandardCharsets.UTF_8);
  10.         }
  11.         return null;
  12.     }
  13. }
复制代码
3.2 注册更新数据的反序列

注意: 需要通过 EventDeserializer 来举行注册
  1. // 反序列化配置
  2. EventDeserializer eventDeserializer = new EventDeserializer();
  3. Field field = EventDeserializer.class.getDeclaredField("tableMapEventByTableId");
  4. field.setAccessible(true);
  5. Map<Long, TableMapEventData> tableMapEventByTableId = (Map<Long, TableMapEventData>) field.get(eventDeserializer);
  6. eventDeserializer.setEventDataDeserializer(EventType.EXT_UPDATE_ROWS, new CustomUpdateRowsEventDataDeserializer(tableMapEventByTableId)
  7.         .setMayContainExtraInformation(true));
复制代码
3.3 更新text范例的字段,看输出的结果


4、只订阅感爱好的事件
  1. // 反序列化配置
  2. EventDeserializer eventDeserializer = new EventDeserializer();
  3. eventDeserializer.setCompatibilityMode(
  4.          // 将日期类型的数据反序列化成Long类型
  5.          EventDeserializer.CompatibilityMode.DATE_AND_TIME_AS_LONG
  6. );
  7. // 表示对 删除事件不感兴趣 ( 对于DELETE事件的反序列化直接返回null )
  8. eventDeserializer.setEventDataDeserializer(EventType.EXT_DELETE_ROWS, new NullEventDataDeserializer());
复制代码
对于不感爱好的事件直接利用NullEventDataDeserializer,可以提高程序的性能。
5、断点续传

当binlog的信息发生变更时,需要生存起来,下次程序重新启动时,读取之前生存好的binlog信息。
5.1 binlog信息长期化

此处为了模拟,将binlog的信息生存到文件中。
  1. /**
  2. * binlog position 的持久化处理
  3. *
  4. * @author huan.fu
  5. * @date 2024/10/11 - 12:54
  6. */
  7. public class FileBinlogPositionHandler {
  8.     /**
  9.      * binlog 信息实体类
  10.      */
  11.     public static class BinlogPositionInfo {
  12.         /**
  13.          * binlog文件的名字
  14.          */
  15.         public String binlogName;
  16.         /**
  17.          * binlog的位置
  18.          */
  19.         private Long position;
  20.         /**
  21.          * binlog的server id的值
  22.          */
  23.         private Long serverId;
  24.     }
  25.     /**
  26.      * 保存binlog信息
  27.      *
  28.      * @param binlogName binlog文件名
  29.      * @param position   binlog位置信息
  30.      * @param serverId   binlog server id
  31.      */
  32.     public void saveBinlogInfo(String binlogName, Long position, Long serverId) {
  33.         List<String> data = new ArrayList<>(3);
  34.         data.add(binlogName);
  35.         data.add(position + "");
  36.         data.add(serverId + "");
  37.         try {
  38.             Files.write(Paths.get("binlog-info.txt"), data);
  39.         } catch (IOException e) {
  40.             throw new RuntimeException(e);
  41.         }
  42.     }
  43.     /**
  44.      * 获取 binlog 信息
  45.      *
  46.      * @return BinlogPositionInfo
  47.      */
  48.     public BinlogPositionInfo retrieveBinlogInfo() {
  49.         try {
  50.             List<String> lines = Files.readAllLines(Paths.get("binlog-info.txt"));
  51.             BinlogPositionInfo info = new BinlogPositionInfo();
  52.             info.binlogName = lines.get(0);
  53.             info.position = Long.parseLong(lines.get(1));
  54.             info.serverId = Long.parseLong(lines.get(2));
  55.             return info;
  56.         } catch (IOException e) {
  57.             throw new RuntimeException(e);
  58.         }
  59.     }
  60. }
复制代码
5.2、构建BinaryLogClient时,通报已存在的binlog信息
  1. // 设置 binlog 信息
  2. FileBinlogPositionHandler fileBinlogPositionHandler = new FileBinlogPositionHandler();
  3. FileBinlogPositionHandler.BinlogPositionInfo binlogPositionInfo = fileBinlogPositionHandler.retrieveBinlogInfo();
  4. if (null != binlogPositionInfo) {
  5.     log.info("获取到了binlog 信息 binlogName: {} position: {} serverId: {}", binlogPositionInfo.binlogName,
  6.             binlogPositionInfo.position, binlogPositionInfo.serverId);
  7.     client.setBinlogFilename(binlogPositionInfo.binlogName);
  8.     client.setBinlogPosition(binlogPositionInfo.position);
  9.     client.setServerId(binlogPositionInfo.serverId);
  10. }
复制代码
5.3 更新binlog信息
  1. // FORMAT_DESCRIPTION(写入每个二进制日志文件前的描述事件) HEARTBEAT(心跳事件)这2个事件不进行binlog位置的记录
  2. if (eventType != EventType.FORMAT_DESCRIPTION && eventType != EventType.HEARTBEAT) {
  3.     // 当有binlog文件切换时产生
  4.     if (event.getData() instanceof RotateEventData) {
  5.         RotateEventData eventData = event.getData();
  6.         // 保存binlog position 信息
  7.         fileBinlogPositionHandler.saveBinlogInfo(eventData.getBinlogFilename(), eventData.getBinlogPosition(), event.getHeader().getServerId());
  8.     } else {
  9.         // 非 rotate 事件,保存位置信息
  10.         EventHeaderV4 header = event.getHeader();
  11.         FileBinlogPositionHandler.BinlogPositionInfo info = fileBinlogPositionHandler.retrieveBinlogInfo();
  12.         long position = header.getPosition();
  13.         long serverId = header.getServerId();
  14.         fileBinlogPositionHandler.saveBinlogInfo(info.binlogName, position, serverId);
  15.     }
  16. }
复制代码
5.4 演示


  • 启动程序
  • 修改 address 的值为 地点-update2
  • 克制程序
  • 修改address的值为地点-offline-update
  • 启动程序,看能否收到 上一步修改address的值为地点-offline-update的事件

5、参考地点


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

科技颠覆者

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表