MySQL-CDC 新增同步表确无法捕获增量问题处理

news2024/10/4 13:50:59

Flink-CDC版本:2.3.0

问题描述

之前通过Flink-CDC捕获Mysql数据库的数据变更情况,代码大致如下:

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(flinkEnvConf);

MySqlSource<String> mysql = MySqlSource.<String>builder()
                .hostname(host)
                .port(port)
                .serverId(serverId)
                .username(username)
                .password(password)
                .databaseList(database)
                .tableList(tableList)
                .startupOptions(startupOptions)
                .debeziumProperties(debeziumProp)
                .jdbcProperties(jdbcProp)
                .deserializer(new JsonDebeziumDeserializationSchema())
                .build();
        DataStreamSource<String> mySQLSource = env.fromSource(mysql, WatermarkStrategy.noWatermarks(), "MySQL Source");
        mySQLSource.print();

debezium.database.history=com.ververica.cdc.connectors.mysql.debezium.EmbeddedFlinkDatabaseHistory

并且我是开启的checkpoint,并且重启程序后是从checkpoint进行恢复的

一开始同步一张表table_a的增量数据,发现没问题,后续新增表table_b,在捕获table_b的数据时,发现异常:

Encountered change event 'Event{header=EventHeaderV4{timestamp=170917       7391000, eventType=TABLE_MAP, serverId=1, headerLength=19, dataLength=117, nextPosition=769436194, flags=0}, data=TableMapEventData{tableId=5303, database='test', table='table_b', columnTypes=8, 15, 18, 18, 18, 18, 18, 18, 18, 18, 18, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 1, 15, columnMetadata=0, 192,        0, 0, 0, 0, 0, 0, 0, 0, 0, 192, 96, 96, 96, 96, 96, 384, 96, 96, 384, 30, 30, 30, 30, 0, 96, columnNullability={5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 1       9, 20, 21, 22, 23, 24, 25, 26}, eventMetadata=TableMapEventMetadata{signedness={1}, defaultCharset=33, charsetCollations=null, columnCharsets=null, columnNames=null,        setStrValues=null, enumStrValues=null, geometryTypes=null, simplePrimaryKeys=null, primaryKeysWithPrefix=null, enumAndSetDefaultCharset=null, enumAndSetColumnCharse       ts=null,visibility=null}}}' at offset {transaction_id=null, ts_sec=1709177391, file=binlog.000476, pos=769435520, server_id=1, event=3} for table test.table_b whose schema isn't known to this connector. One possible cause is an incomplete database history topic. Take a new snapshot in this case.
101065 Use the mysqlbinlog tool to view the problematic event: mysqlbinlog --start-position=769436058 --stop-position=769436194 --verbose binlog.000476

问题解决

结合debezium的源码,并且在网上找了一下相关方案如下:
链接:https://help.aliyun.com/zh/flink/support/faq-about-cdc#section-nbg-sb4-ebe

主要是两个点
1、不建议使用配置'debezium.snapshot.mode'='never'
2、通过'debezium.inconsistent.schema.handling.mode' = 'warn'参数避免报错

针对1:不使用'debezium.snapshot.mode'='never'意味着每次重启CDC进程的时候,就要重新消费一遍同步表的所有数据,无法满足业务需求
针对2:修改配置'debezium.inconsistent.schema.handling.mode' = 'warn',其实这种办法是治标不治本,修改配置只是让程序打印warn日志,代码可以继续运行,还是无法解决无法捕获增量的问题;

没办法,只能debug源码来发现问题了。先从报错位置开始看起

MySqlStreamingChangeEventSource

private void informAboutUnknownTableIfRequired(
        MySqlOffsetContext offsetContext, Event event, TableId tableId, String typeToLog) {
    if (tableId != null
            && connectorConfig.getTableFilters().dataCollectionFilter().isIncluded(tableId)) {
        metrics.onErroneousEvent("source = " + tableId + ", event " + event);
        EventHeaderV4 eventHeader = event.getHeader();

        if (inconsistentSchemaHandlingMode == EventProcessingFailureHandlingMode.FAIL) {
            LOGGER.error(
                    "Encountered change event '{}' at offset {} for table {} whose schema isn't known to this connector. One possible cause is an incomplete database history topic. Take a new snapshot in this case.{}"
                            + "Use the mysqlbinlog tool to view the problematic event: mysqlbinlog --start-position={} --stop-position={} --verbose {}",
                    event,
                    offsetContext.getOffset(),
                    tableId,
                    System.lineSeparator(),
                    eventHeader.getPosition(),
                    eventHeader.getNextPosition(),
                    offsetContext.getSource().binlogFilename());
            throw new DebeziumException(
                    "Encountered change event for table "
                            + tableId
                            + " whose schema isn't known to this connector");
        } else if (inconsistentSchemaHandlingMode == EventProcessingFailureHandlingMode.WARN) {
            LOGGER.warn(
                    "Encountered change event '{}' at offset {} for table {} whose schema isn't known to this connector. One possible cause is an incomplete database history topic. Take a new snapshot in this case.{}"
                            + "The event will be ignored.{}"
                            + "Use the mysqlbinlog tool to view the problematic event: mysqlbinlog --start-position={} --stop-position={} --verbose {}",
                    event,
                    offsetContext.getOffset(),
                    tableId,
                    System.lineSeparator(),
                    System.lineSeparator(),
                    eventHeader.getPosition(),
                    eventHeader.getNextPosition(),
                    offsetContext.getSource().binlogFilename());
        } else {
            LOGGER.debug(
                    "Encountered change event '{}' at offset {} for table {} whose schema isn't known to this connector. One possible cause is an incomplete database history topic. Take a new snapshot in this case.{}"
                            + "The event will be ignored.{}"
                            + "Use the mysqlbinlog tool to view the problematic event: mysqlbinlog --start-position={} --stop-position={} --verbose {}",
                    event,
                    offsetContext.getOffset(),
                    tableId,
                    System.lineSeparator(),
                    System.lineSeparator(),
                    eventHeader.getPosition(),
                    eventHeader.getNextPosition(),
                    offsetContext.getSource().binlogFilename());
        }
    } else {
        LOGGER.debug(
                "Filtering {} event: {} for non-monitored table {}", typeToLog, event, tableId);
        metrics.onFilteredEvent("source = " + tableId);
    }
}


protected void handleUpdateTableMetadata(MySqlOffsetContext offsetContext, Event event) {
    TableMapEventData metadata = unwrapData(event);
    long tableNumber = metadata.getTableId();
    String databaseName = metadata.getDatabase();
    String tableName = metadata.getTable();
    TableId tableId = new TableId(databaseName, null, tableName);
    // 获取了日志变更信息,根据tableId(表名)在判断缓存中是否存在
    // 如果是新增表,在taskContext.getSchema() 对象中是不存在的
    if (taskContext.getSchema().assignTableNumber(tableNumber, tableId)) {
        LOGGER.debug("Received update table metadata event: {}", event);
    } else {
        informAboutUnknownTableIfRequired(
                offsetContext, event, tableId, "update table metadata");
    }
}

MySqlDatabaseSchema

public boolean assignTableNumber(long tableNumber, TableId id) {
    // 通过schemaFor
    final TableSchema tableSchema = schemaFor(id);
    if (tableSchema == null) {
        return false;
    }

    tableIdsByTableNumber.put(tableNumber, id);
    return true;
}

RelationalDatabaseSchema

@Override
public TableSchema schemaFor(TableId id) {
    // 最终是从schemasByTableId对象中取值
    // schemasByTableId 对象通过ConcurrentMap存储
    // 现在我们需要知道,ConcurrentMap 是什么时候将数据添加进去的
    return schemasByTableId.get(id);
}

// 通过debug发现,调用下面这个方法,我们需要知道是谁在调用此方法
protected void buildAndRegisterSchema(Table table) {
    if (tableFilter.isIncluded(table.id())) {
        TableSchema schema = schemaBuilder.create(schemaPrefix, getEnvelopeSchemaName(table), table, columnFilter, columnMappers, customKeysMapper);
        schemasByTableId.put(table.id(), schema);
    }
}

HistorizedRelationalDatabaseSchema

// 在前面,我设置的配置是:debezium.database.history=com.ververica.cdc.connectors.mysql.debezium.EmbeddedFlinkDatabaseHistory
@Override
public void recover(OffsetContext offset) {
    if (!databaseHistory.exists()) {
        String msg = "The db history topic or its content is fully or partially missing. Please check database history topic configuration and re-execute the snapshot.";
        throw new DebeziumException(msg);
    }
    // 当我们断点在这里的时候,发现tables(), tableIds()是没有数据的
    databaseHistory.recover(offset.getPartition(), offset.getOffset(), tables(), getDdlParser());
    // 当我们断点在这里的时候,发现tables(), tableIds()是有数据的
    // recover() 这个方法时完成了赋值
    // tables(), tableIds() 里面的数据,就是我们要的schema信息
    recoveredTables = !tableIds().isEmpty();
    for (TableId tableId : tableIds()) {
        buildAndRegisterSchema(tableFor(tableId));
    }
}

EmbeddedFlinkDatabaseHistory

@Override
public void recover(
        Map<String, ?> source, Map<String, ?> position, Tables schema, DdlParser ddlParser) {
    listener.recoveryStarted();
    // schema 里面的值其实就是从tableSchemas里面遍历得到的
    for (TableChange tableChange : tableSchemas.values()) {
        schema.overwriteTable(tableChange.getTable());
    }
    listener.recoveryStopped();
}


@Override
public void configure(
        Configuration config,
        HistoryRecordComparator comparator,
        DatabaseHistoryListener listener,
        boolean useCatalogBeforeSchema) {
    this.listener = listener;
    this.storeOnlyMonitoredTablesDdl = config.getBoolean(STORE_ONLY_MONITORED_TABLES_DDL);
    this.skipUnparseableDDL = config.getBoolean(SKIP_UNPARSEABLE_DDL_STATEMENTS);

    // recover
    String instanceName = config.getString(DATABASE_HISTORY_INSTANCE_NAME);
    this.tableSchemas = new HashMap<>();
    // tableSchemas 里面的值是通过removeHistory(instanceName)获取的
    for (TableChange tableChange : removeHistory(instanceName)) {
        tableSchemas.put(tableChange.getId(), tableChange);
    }
}

// 这个方法的返回值是TABLE_SCHEMAS 返回的,所以要搞清楚
// TABLE_SCHEMAS在何时赋值的
public static Collection<TableChange> removeHistory(String engineName) {
    if (engineName == null) {
        return Collections.emptyList();
    }
    // 
    Collection<TableChange> tableChanges = TABLE_SCHEMAS.remove(engineName);
    return tableChanges != null ? tableChanges : Collections.emptyList();
}

// 在此方法下,TABLE_SCHEMAS 完成赋值
// 是谁在调用此方法
public static void registerHistory(String engineName, Collection<TableChange> engineHistory) {
    TABLE_SCHEMAS.put(engineName, engineHistory);
}

StatefulTaskContext

// configure()内部调用registerHistory完成schema的赋值
// 其实就是调用:mySqlSplit.getTableSchemas().values() 完成对schema的赋值
public void configure(MySqlSplit mySqlSplit) {
    // initial stateful objects
    final boolean tableIdCaseInsensitive = connection.isTableIdCaseSensitive();
    this.topicSelector = MySqlTopicSelector.defaultSelector(connectorConfig);
    EmbeddedFlinkDatabaseHistory.registerHistory(
            sourceConfig
                    .getDbzConfiguration()
                    .getString(EmbeddedFlinkDatabaseHistory.DATABASE_HISTORY_INSTANCE_NAME),
            mySqlSplit.getTableSchemas().values());
    ...
    ...
}

总结:

  1. 为什么新增实时表时,新增表的增量数据无法捕获?

因为RelationalDatabaseSchema对象内部,有一个对象Tables,Tables内部并没有保存新增表的schema信息,在解析到新增表的增量数据时会判断Tables内是否存在这个表,如果不存在会直接将这张表的增量数据过滤

  1. Tables对象内的schema信息是怎么获取到的?

通过上面源码从下到上的解析可以发现,Tables对象内的schema信息是通过MySqlSplit 这个对象传进来的,我们现在需要搞明白,MySqlSplit是怎么获取到的。

下面这段代码流程比较简单,直接写出来
image.png

1com.ververica.cdc.connectors.mysql.source.reader.MySqlSourceReader#addSplits
2org.apache.flink.connector.base.source.reader.SourceReaderBase#addSplits
3org.apache.flink.connector.base.source.reader.fetcher.SingleThreadFetcherManager#addSplits
4org.apache.flink.connector.base.source.reader.fetcher.SplitFetcherManager#startFetcher
5java.util.concurrent.AbstractExecutorService#submit(java.lang.Runnable)
这边是多线程异常提交:org.apache.flink.connector.base.source.reader.fetcher.SplitFetcher
6org.apache.flink.connector.base.source.reader.fetcher.SplitFetcher#run
7org.apache.flink.connector.base.source.reader.fetcher.SplitFetcher#runOnce
8org.apache.flink.connector.base.source.reader.fetcher.FetchTask#run
9com.ververica.cdc.connectors.mysql.source.reader.MySqlSplitReader#fetch
10com.ververica.cdc.connectors.mysql.source.reader.MySqlSplitReader#checkSplitOrStartNext
11com.ververica.cdc.connectors.mysql.debezium.reader.BinlogSplitReader#submitSplit
12com.ververica.cdc.connectors.mysql.debezium.task.context.StatefulTaskContext#configure

最终调用:configure()

主要看下面:

// 此方法的入参splits,是flink通过savepoint恢复,从state中获取的
// 如果之前只捕获table_A表的增量,那么splits对象内部只有table_A的schema信息
// 如果此程序是第一次启动,那么splits中是没有任何一张表的shcema信息,那么flink-cdc代码是肯定有去获取表的schema信息的实现
// 下面看discoverTableSchemasForBinlogSplit()
@Override
public void addSplits(List<MySqlSplit> splits) {
    // restore for finishedUnackedSplits
    List<MySqlSplit> unfinishedSplits = new ArrayList<>();
    for (MySqlSplit split : splits) {
        LOG.info("Add Split: " + split);
        if (split.isSnapshotSplit()) {
            MySqlSnapshotSplit snapshotSplit = split.asSnapshotSplit();
            snapshotSplit = discoverTableSchemasForSnapshotSplit(snapshotSplit);
            if (snapshotSplit.isSnapshotReadFinished()) {
                finishedUnackedSplits.put(snapshotSplit.splitId(), snapshotSplit);
            } else {
                unfinishedSplits.add(split);
            }
        } else {
            MySqlBinlogSplit binlogSplit = split.asBinlogSplit();
            // the binlog split is suspended
            if (binlogSplit.isSuspended()) {
                suspendedBinlogSplit = binlogSplit;
            } else if (!binlogSplit.isCompletedSplit()) {
                uncompletedBinlogSplits.put(split.splitId(), split.asBinlogSplit());
                requestBinlogSplitMetaIfNeeded(split.asBinlogSplit());
            } else {
                uncompletedBinlogSplits.remove(split.splitId());
                MySqlBinlogSplit mySqlBinlogSplit =
                        discoverTableSchemasForBinlogSplit(split.asBinlogSplit());
                unfinishedSplits.add(mySqlBinlogSplit);
            }
        }
    }
    // notify split enumerator again about the finished unacked snapshot splits
    reportFinishedSnapshotSplitsIfNeed();
    // add all un-finished splits (including binlog split) to SourceReaderBase
    if (!unfinishedSplits.isEmpty()) {
        super.addSplits(unfinishedSplits);
    }
}


private MySqlBinlogSplit discoverTableSchemasForBinlogSplit(MySqlBinlogSplit split) {
    final String splitId = split.splitId();
    // 当split == null时,才会去获取所有cdc表的schema信息
    // 如果我是从state恢复,split肯定 != null
    // 真正需要改的地方就是这里,我比较暴力,直接改为if(true)
    if (split.getTableSchemas().values().isEmpty()) {
        try (MySqlConnection jdbc = DebeziumUtils.createMySqlConnection(sourceConfig)) {
            Map<TableId, TableChanges.TableChange> tableSchemas =
                    TableDiscoveryUtils.discoverCapturedTableSchemas(sourceConfig, jdbc);
            LOG.info("The table schema discovery for binlog split {} success", splitId);
            return MySqlBinlogSplit.fillTableSchemas(split, tableSchemas);
        } catch (SQLException e) {
            LOG.error("Failed to obtains table schemas due to {}", e.getMessage());
            throw new FlinkRuntimeException(e);
        }
    } else {
        LOG.warn(
                "The binlog split {} has table schemas yet, skip the table schema discovery",
                split);
        return split;
    }
}

image.png

重新打包编译后测试,之前的问题已经解决。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1480228.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

智能咖啡厅助手:人形机器人 +融合大模型,行为驱动的智能咖啡厅机器人(机器人大模型与具身智能挑战赛)

智能咖啡厅助手&#xff1a;人形机器人 融合大模型&#xff0c;行为驱动的智能咖啡厅机器人(机器人大模型与具身智能挑战赛) “机器人大模型与具身智能挑战赛”的参赛作品。的目标是结合前沿的大模型技术和具身智能技术&#xff0c;开发能在模拟的咖啡厅场景中承担服务员角色并…

adb下载安装及使用教程

adb下载安装及使用教程 一、ADB的介绍1.ADB是什么&#xff1f;2.内容简介3.ADB常用命令1. ADB查看设备2. ADB安装软件3. ADB卸载软件4. ADB登录设备shell5. ADB从电脑上发送文件到设备6. ADB从设备上下载文件到电脑7. ADB显示帮助信息 4.为什么要用ADB 二、ADB的下载1.Windows版…

「MySQL」增删查改

在操作数据库中的表时&#xff0c;需要先使用该数据库&#xff1a; use database;新增 创建表 先用 use 指定一个数据库,然后使用 create 新增一个表 比如建立一个学生表 mysql> use goods; mysql> create table student(-> name varchar(4),-> age int,-> …

Linux系统---nginx(4)负载均衡

目录 1、服务器配置指令 ​编辑 1.1 服务器指令表 1.2 服务器指令参数 2、负载均衡策略指令 2.1 轮询 &#xff08;1) 加权轮询 &#xff08;2) 平滑轮询 2.2 URL 哈希&#xff08;一致性哈希&#xff09; 2.3 IP哈希策略 2.4 最少连接 Nginx 负载均衡是由代理模块和上…

爱心商城|爱心商城系统|基于Springboot的爱心商城系统设计与实现(源码+数据库+文档)

爱心商城系统目录 目录 基于Springboot的爱心商城系统设计与实现 一、前言 二、系统功能设计 三、系统功能设计 1、商品管理 2、捐赠管理 3、公告管理 4、公告类型管理 四、数据库设计 五、核心代码 六、论文参考 七、最新计算机毕设选题推荐 八、源码获取&#x…

ssm274办公自动化管理系统

** &#x1f345;点赞收藏关注 → 私信领取本源代码、数据库&#x1f345; 本人在Java毕业设计领域有多年的经验&#xff0c;陆续会更新更多优质的Java实战项目希望你能有所收获&#xff0c;少走一些弯路。&#x1f345;关注我不迷路&#x1f345;** 一 、设计说明 1.1课题背…

图结构数据的构建-DGL库

官方文档 一、图的特点 同构性与异构性 相比同构图&#xff0c;异构图里可以有不同类型的节点和边。这些不同类型的节点和边具有独立的ID空间和特征&#xff1b;同构图和二分图只是一种特殊的异构图&#xff0c;它们只包括一种关系 节点与边 有向图一条边、无向图两条边、…

天津廉租房如何申请取得廉租住房租房补贴资格

如何申请廉租住房租赁补贴资格&#xff1f; 低收入住房困难家庭应当向户籍所在地街道办事处&#xff08;乡镇人民政府&#xff09;提出申请。 申请时&#xff0c;您需要提供以下要求的原件和复印件&#xff1a; &#xff08;一&#xff09;您及家人的身份证件&#xff1b; &a…

React富文本编辑器开发(二)

我们接着上一节的示例内容&#xff0c;现在有如下需求&#xff0c;我们希望当我们按下某个按键时编辑器有所反应。这就需要我们对编辑器添加事件功能onKeyDown, 我们给 Editor添加事件&#xff1a; SDocor.jsx import { useState } from react; import { createEditor } from…

羊大师分享,羊奶奶有哪些对健康有益的喝法?

羊大师分享&#xff0c;羊奶奶有哪些对健康有益的喝法&#xff1f; 羊奶奶有多种对健康有益的喝法&#xff0c;以下是一些建议&#xff1a; 直接饮用&#xff1a;将羊奶直接煮沸后饮用&#xff0c;可以保留羊奶中的营养成分&#xff0c;为身体提供全面的滋养。羊奶的丰富蛋白质…

从李一舟看AI浪潮: 聚合数据教你如何把握数据的真正价值

AI热潮的追捧与质疑 在人工智能&#xff08;AI&#xff09;技术的浪潮中&#xff0c;每天都有新的进展让我们惊叹不已。最近&#xff0c;OpenAI的Sora模型如同一颗璀璨的明星&#xff0c;闪耀在科技界的夜空。与此同时&#xff0c;各种AI相关的产品和课程如同春雨后的竹笋&…

四川易点慧电子商务有限公司抖音小店靠谱吗?

在当下电商行业风起云涌的时代&#xff0c;四川易点慧电子商务有限公司作为抖音小店的一家新兴力量&#xff0c;是否靠谱成为了许多消费者和创业者关注的焦点。今天&#xff0c;我们就来深度解析一下这家公司&#xff0c;看看它的抖音小店究竟靠不靠谱。 一、公司背景介绍 四川…

【Mars3d】进行水平测量measure.area({的时候,会被模型遮挡的处理方法

问题&#xff1a; 1.thing/analysis/measure 水平面积 measure.area({ 在模型上测量的时候会被遮挡 2. 通过 addHeight:10000,增加高度也不可以实现这种被遮挡的效果&#xff0c;都增加到10000了&#xff0c;还是会被遮挡 export function measureArea() { measure.area({ s…

释放 群星聚落 时 自动魔免【War3地图编辑器】

文章目录 前言实现原理具体步骤1、创建隐形单位2、新建触发器2.1、新事件开端 2.2、环境→新条件2.3、动作2.3.1、创建单位2.3.2、单位 发布指令(指定单位) 前言 白虎 在斗蛐蛐中又称 白给&#xff0c;因为战绩长期倒数单挑能力和大法师并列倒数第一然而在实战中&#xff0c;大…

【Linux C | 网络编程】getaddrinfo 函数详解及C语言例子

&#x1f601;博客主页&#x1f601;&#xff1a;&#x1f680;https://blog.csdn.net/wkd_007&#x1f680; &#x1f911;博客内容&#x1f911;&#xff1a;&#x1f36d;嵌入式开发、Linux、C语言、C、数据结构、音视频&#x1f36d; &#x1f923;本文内容&#x1f923;&a…

视频和音频使用ffmpeg进行合并和分离(MP4)

1.下载ffmpeg 官网地址&#xff1a;https://ffmpeg.org/download.html 2.配置环境变量 此电脑右键点击 属性 - 高级系统配置 -高级 -环境变量 - 系统变量 path 新增 文件的bin路径 3.验证配置成功 ffmpeg -version 返回版本信息说明配置成功4.执行合并 ffmpeg -i 武家坡20…

群控代理IP搭建教程:打造一流的网络爬虫

目录 前言 一、什么是群控代理IP&#xff1f; 二、搭建群控代理IP的步骤 1. 获取代理IP资源 2. 配置代理IP池 3. 选择代理IP策略 4. 编写代理IP设置代码 5. 异常处理 三、总结 前言 群控代理IP是一种常用于网络爬虫的技术&#xff0c;通过使用多个代理IP实现并发请求…

【设计模式】(二、)设计模式六大设计原则

一、 设计原则概述 设计模式中主要有六大设计原则&#xff0c;简称为SOLID &#xff0c;是由于各个原则的首字母简称合并的来(两个L算一个,solid 稳定的)&#xff0c;六大设计原则分别如下&#xff1a; ​ 1、单一职责原则&#xff08;Single Responsibitity Principle&#…

Flutter SDK 常见问题

镜像配置 配置pub服务的镜像地址&#xff1a; export PUB_HOSTED_URLhttps://pub.flutter-io.cn export FLUTTER_STORAGE_BASE_URLhttps://storage.flutter-io.cn 第一次运行项目很慢&#xff0c;搜索整个Flutter SDK项目&#xff0c;使用以下内容替换google和mavenCentral仓…

【精选】Java项目介绍和界面搭建——拼图小游戏 中

&#x1f36c; 博主介绍&#x1f468;‍&#x1f393; 博主介绍&#xff1a;大家好&#xff0c;我是 hacker-routing &#xff0c;很高兴认识大家~ ✨主攻领域&#xff1a;【渗透领域】【应急响应】 【Java】 【VulnHub靶场复现】【面试分析】 &#x1f389;点赞➕评论➕收藏 …