【Java进阶】深度解析Canal:从原理到实战,MySQL增量数据同步的利器

💻 Hello World, 我是 予枫。

代码不止,折腾不息。作为一个正在升级打怪的 Java 后端练习生,我喜欢把踩过的坑和学到的招式记录下来。 保持空杯心态,让我们开始今天的技术分享。

在分布式系统架构中,数据同步是绕不开的核心议题。尤其是基于MySQL的业务系统,如何实时捕获数据库增量变更、实现跨系统数据流转,直接影响到缓存一致性、检索效率、数据分析等关键环节。而Canal,这款由阿里巴巴开源的分布式数据库同步系统,就像一条高效的"数据运河",完美解决了MySQL增量数据订阅与消费的痛点。本文将从原理、实操、场景、优化等多个维度,带大家全面掌握Canal的核心价值与使用技巧。

一、初识Canal:它是什么,能解决什么问题?

Canal(译意为"水道/管道")是阿里巴巴开源的一款基于MySQL二进制日志(Binary Log)解析的增量数据同步工具,其核心定位是提供可靠的MySQL增量数据订阅与消费能力。简单来说,Canal就是一座连接MySQL与下游系统(Redis、Elasticsearch、Kafka等)的"桥梁",能够实时捕获MySQL的数据新增、修改、删除等变更操作,并将这些变更同步到指定的存储或消息中间件中。

在Canal出现之前,传统的数据同步方案往往存在诸多局限:

  • 轮询查询:定时查询数据库实现数据同步,存在延迟高、资源消耗大的问题,无法满足实时业务需求;

  • 触发器同步:通过MySQL触发器捕获数据变更,会侵入业务表结构,增加数据库负担,且在高并发场景下性能堪忧;

  • 主从复制:MySQL原生主从复制仅能实现数据备份,无法灵活对接下游多样化的消费场景。

而Canal凭借"无侵入、低延迟、高可靠"的特性,完美解决了上述问题。其开源以来,已在阿里巴巴内部经过海量业务验证,广泛应用于数据同步、缓存更新、数据库监控、数据备份与迁移等场景,成为分布式架构中的核心基础设施之一。

二、核心原理:Canal如何"捕获"MySQL数据变更?

Canal的工作原理核心的一句话:模拟MySQL从库(Slave)的复制流程,解析主库(Master)的Binlog日志。要理解这个过程,我们首先需要回顾MySQL主从复制的核心机制,再看Canal如何"伪装"成从库参与其中。

2.1 MySQL主从复制的核心流程

MySQL主从复制是保障数据高可用的基础机制,其核心流程分为三步:

  1. 主库(Master)将数据变更写入二进制日志(Binlog),这是主从复制的核心数据源;

  2. 从库(Slave)向主库发送Dump请求,获取主库的Binlog日志并写入本地的中继日志(Relay Log);

  3. 从库读取中继日志,重放其中的SQL操作,将数据同步到本地数据库,最终实现主从数据一致。

2.2 Canal的工作机制拆解

Canal的核心设计思路就是"伪装"成一个MySQL从库,完整参与主从复制的流程,从而实现对Binlog日志的解析与数据捕获。具体步骤如下:

  1. 伪装从库,建立连接:Canal启动后,会向MySQL主库发送一个Slave连接请求,告知主库自己是一个从库,并提供伪造的Slave ID;

  2. 获取Binlog,开始同步:主库通过验证后,会按照Canal指定的Binlog文件名和偏移量,将后续的Binlog日志持续推送给Canal;

  3. 解析Binlog,提取变更:Canal接收Binlog日志后,通过内置的解析器(支持Row、Statement、Mixed三种Binlog格式,推荐Row模式,数据更精准)解析日志内容,提取出数据变更的详细信息(表名、操作类型INSERT/UPDATE/DELETE、变更前后的数据等);

  4. 数据投递,下游消费:Canal将解析后的变更数据,通过TCP、Kafka、RocketMQ等方式投递到下游消费端,消费端可以根据业务需求进行数据处理(如更新缓存、写入检索引擎、数据备份等)。

这里需要特别说明:Row模式的Binlog会记录每一行数据的完整变更,即使是批量更新操作,也会逐行记录变更前后的状态,这使得Canal能够精准捕获每一条数据的变更细节,避免了Statement模式下的SQL语法兼容问题,是生产环境的首选配置。

三、实战部署:从环境准备到客户端开发

理论了解清楚后,我们通过一个完整的实战案例,实现"MySQL数据变更 → Canal捕获 → 同步至Redis/Elasticsearch"的全流程。本次实战基于Canal 1.1.6版本(稳定版),结合Spring Boot开发客户端。

3.1 前置环境准备

本次实战需要准备以下环境:

  • MySQL 8.0(需开启Binlog并设置为Row模式);

  • Canal Server 1.1.6;

  • Spring Boot 2.7.x(客户端开发);

  • Redis 6.x、Elasticsearch 7.x(下游存储)。

3.2 MySQL配置(关键步骤)

Canal的正常工作依赖MySQL的Binlog功能,因此需要先配置MySQL:

  1. 修改MySQL配置文件(my.cnf或my.ini),添加以下配置:

    复制代码
      [mysqld]
    # 开启Binlog
    log-bin=mysql-bin
    # 设置Binlog格式为Row模式
    binlog-format=ROW
    # 主库唯一ID(1-2^32-1)
    server-id=1
    # 只记录指定数据库的Binlog(可选,默认全部记录)
    # binlog-do-db=test
    # 忽略指定数据库的Binlog(可选)
    # binlog-ignore-db=mysql
  2. 重启MySQL服务,并验证配置是否生效:

    复制代码
    -- 登录MySQL后执行
    show variables like 'log_bin'; -- 应返回ON
    show variables like 'binlog_format'; -- 应返回ROW
  3. 创建Canal专属用户并授权

    复制代码
    -- 创建用户(用户名/密码:canal/canal)
    CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
    -- 授予复制权限和查询权限
    GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal'@'%';
    -- 刷新权限
    FLUSH PRIVILEGES;

3.3 Canal Server部署与配置

  1. 下载Canal Server:从Canal官方 Releases页面下载1.1.6版本的canal.deployer-1.1.6.tar.gz;

  2. 解压并修改配置

    复制代码
    # 解压
    tar -zxvf canal.deployer-1.1.6.tar.gz -C /usr/local/canal
    # 进入实例配置目录(默认实例为example)
    cd /usr/local/canal/conf/example
    # 修改instance.properties配置文件
    vi instance.properties核心配置项(其他默认即可):
              # MySQL主库地址和端口
    canal.instance.master.address=127.0.0.1:3306
    # MySQL用户名密码(刚才创建的canal用户)
    canal.instance.dbUsername=canal
    canal.instance.dbPassword=canal
    # 监听的表(正则表达式,.*\\..*表示所有库所有表,可指定如test\\.user)
    canal.instance.filter.regex=.*\\..*
    # Binlog起始位置(首次启动可留空,默认从最新位置开始)
    canal.instance.master.journal.name=
    canal.instance.master.position=
    canal.instance.master.timestamp=
    canal.instance.master.gtid=
    # 启用DDL语句解析(可选,需要同步表结构变更时开启)
    canal.instance.get.ddl.isolation=true
  3. 启动Canal Server

    复制代码
    # 进入bin目录
    cd /usr/local/canal/bin
    # 启动(后台启动:sh startup.sh &)
    sh startup.sh
    # 查看日志,验证启动是否成功
    tail -f /usr/local/canal/logs/canal/canal.log

    若日志中出现"the canal server is running now",则说明Canal Server启动成功。

3.4 Spring Boot客户端开发(同步Redis/ES)

客户端的核心功能是连接Canal Server,订阅数据变更,然后将变更同步到Redis和Elasticsearch。

3.4.1 引入依赖

在Spring Boot项目的pom.xml中添加以下依赖:

复制代码
<!-- Canal客户端依赖 -->
<dependency>
    <groupId>com.alibaba.otter</groupId>
    <artifactId>canal.client</artifactId>
    <version>1.1.6</version>
</dependency>
<!-- Redis依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId&gt;
&lt;/dependency&gt;
<!-- Elasticsearch依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
3.4.2 配置文件

在application.yml中添加Canal、Redis、ES的配置:

复制代码
spring:
  redis:
    host: localhost
    port: 6379
    password: 123456
  data:
    elasticsearch:
      cluster-nodes: localhost:9200
      repositories:
        enabled: true
# Canal配置
canal:
  server: 127.0.0.1:11111  # Canal Server默认端口
  destination: example     # 实例名称(与Canal Server配置一致)
  username: canal          # 用户名(默认canal,可在canal.properties中修改)
  password: canal          # 密码(默认canal)
3.4.3 实现Canal监听与同步逻辑

创建CanalListener类,实现数据变更的监听与同步:

java 复制代码
import com.alibaba.fastjson.JSON;
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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Component
public class CanalDataSyncListener {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private ElasticsearchRestTemplate esTemplate;

    // Canal连接参数(从配置文件读取,此处简化硬编码)
    private final String canalServer = "127.0.0.1:11111";
    private final String destination = "example";
    private final String username = "canal";
    private final String password = "canal";

    @PostConstruct
    public void initCanalListener() {
        // 创建Canal连接
        CanalConnector connector = CanalConnectors.newSingleConnector(
                new InetSocketAddress(canalServer.split(":")[0], Integer.parseInt(canalServer.split(":")[1])),
                destination, username, password);

        // 启动独立线程监听Canal消息
        new Thread(() -> {
            try {
                // 连接Canal Server
                connector.connect();
                // 订阅监听的表(.*\\..*表示所有库所有表,可指定如test\\.user)
                connector.subscribe(".*\\..*");
                // 回滚到上次同步的位置(避免重复消费)
                connector.rollback();

                while (true) {
                    // 批量获取消息(100条,超时时间1秒)
                    Message message = connector.getWithoutAck(100, 1000);
                    long batchId = message.getId();
                    int size = message.getEntries().size();

                    if (batchId == -1 || size == 0) {
                        // 无消息时休眠100ms,避免空轮询
                        Thread.sleep(100);
                        continue;
                    }

                    // 处理消息
                    processCanalMessage(message.getEntries());

                    // 确认消息消费成功(批量ACK)
                    connector.ack(batchId);
                }
            } catch (Exception e) {
                e.printStackTrace();
                // 异常时回滚消息
                connector.rollback();
            }
        }).start();
    }

    /**
     * 处理Canal消息,解析数据变更并同步到Redis和ES
     */
    private void processCanalMessage(List<CanalEntry.Entry> entries) {
        for (CanalEntry.Entry entry : entries) {
            // 只处理行数据变更(忽略事务开始、提交等消息)
            if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
                continue;
            }

            try {
                // 解析RowChange对象(包含变更的详细信息)
                CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
                CanalEntry.EventType eventType = rowChange.getEventType();
                String tableName = entry.getHeader().getTableName();

                // 遍历每一行的变更数据
                for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
                    // 解析变更前的数据(DELETE操作时用)
                    Map<String, String> beforeData = parseColumns(rowData.getBeforeColumnsList());
                    // 解析变更后的数据(INSERT/UPDATE操作时用)
                    Map<String, String> afterData = parseColumns(rowData.getAfterColumnsList());

                    // 根据操作类型同步数据
                    switch (eventType) {
                        case INSERT:
                        case UPDATE:
                            // 同步到Redis(key:表名:主键值,value:JSON格式数据)
                            syncToRedis(tableName, afterData);
                            // 同步到ES(索引名:表名小写,文档ID:主键值)
                            syncToElasticsearch(tableName, afterData);
                            break;
                        case DELETE:
                            // 从Redis和ES中删除数据
                            deleteFromRedis(tableName, beforeData);
                            deleteFromElasticsearch(tableName, beforeData);
                            break;
                        default:
                            // 忽略其他操作类型(如DDL)
                            break;
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 解析列数据,转换为Map(key:列名,value:列值)
     */
    private Map<String, String> parseColumns(List<CanalEntry.Column> columns) {
        return columns.stream()
                .collect(Collectors.toMap(
                        CanalEntry.Column::getName,
                        CanalEntry.Column::getValue,
                        (k1, k2) -> k2  // 处理列名重复(理论上不会出现)
                ));
    }

    /**
     * 同步数据到Redis
     */
    private void syncToRedis(String tableName, Map<String, String> data) {
        // 假设表的主键名为id(实际业务中可根据表结构调整)
        String id = data.get("id");
        if (id == null) {
            return;
        }
        String redisKey = tableName + ":" + id;
        redisTemplate.opsForValue().set(redisKey, JSON.toJSONString(data));
    }

    /**
     * 从Redis删除数据
     */
    private void deleteFromRedis(String tableName, Map<String, String> data) {
        String id = data.get("id");
        if (id == null) {
            return;
        }
        String redisKey = tableName + ":" + id;
        redisTemplate.delete(redisKey);
    }

    /**
     * 同步数据到Elasticsearch
     */
    private void syncToElasticsearch(String tableName, Map<String, String> data) {
        String id = data.get("id");
        if (id == null) {
            return;
        }
        // 索引名统一为表名小写,文档ID为数据主键
        esTemplate.save(JSON.toJSONString(data), index -> index
                .index(tableName.toLowerCase())
                .id(id));
    }

    /**
     * 从Elasticsearch删除数据
     */
    private void deleteFromElasticsearch(String tableName, Map<String, String> data) {
        String id = data.get("id");
        if (id == null) {
            return;
        }
        esTemplate.delete(tableName.toLowerCase(), id);
    }
}

代码说明:

  • 通过@PostConstruct注解,在项目启动时初始化Canal连接并启动监听线程;

  • 采用批量获取(getWithoutAck)和批量确认(ack)的方式,提升消费效率,避免重复消费;

  • 解析RowChange对象获取操作类型、表名、变更数据,分别处理INSERT/UPDATE/DELETE操作;

  • 同步Redis时采用"表名:主键"作为key,同步ES时采用"表名小写"作为索引名,符合常规最佳实践。

四、核心应用场景:Canal能帮我们解决哪些业务问题?

Canal的核心价值在于"增量数据的实时捕获与流转",基于这一核心能力,其应用场景覆盖了分布式架构中的多个关键环节:

4.1 缓存一致性保障

这是Canal最常用的场景之一。在分布式系统中,数据库与缓存(Redis)的一致性一直是难题,传统的"更新数据库后更新缓存"的方案,在高并发场景下容易出现缓存脏数据。而通过Canal监听MySQL数据变更,实时更新或删除Redis缓存,能够确保缓存与数据库的数据一致性,且无需侵入业务代码,实现解耦。

4.2 检索引擎数据同步

MySQL的全文检索能力较弱,对于需要复杂检索(如模糊匹配、多条件组合检索)的业务,通常会使用Elasticsearch作为检索引擎。通过Canal实时同步MySQL数据到Elasticsearch,能够确保检索引擎中的数据与业务数据实时一致,满足电商商品检索、日志检索等场景的需求。

4.3 数据库监控与审计

通过Canal捕获MySQL的所有数据变更操作(包括INSERT/UPDATE/DELETE/DDL),可以实现数据库操作的实时监控与审计。例如:监控敏感数据(如用户手机号、身份证号)的修改记录,审计重要业务表(如订单表、支付表)的操作日志,一旦出现异常操作(如批量删除数据),可以及时告警并追溯操作来源。

4.4 数据备份与迁移

Canal可以实时捕获MySQL的增量数据,并将其同步到备库(如MySQL从库、PostgreSQL等),实现数据的实时备份。在数据库版本升级或迁移时,Canal可以先同步历史数据,再实时同步增量数据,确保迁移过程中数据不丢失、业务不中断。

4.5 实时数据分析与数仓建设

对于需要实时数据分析的场景(如实时报表、用户行为分析),Canal可以将MySQL增量数据实时投递到Kafka等消息中间件,然后通过Flink、Spark等流处理框架消费数据,进行实时计算与分析,最终将结果写入数据仓库或展示平台,为业务决策提供实时数据支持。

五、性能优化:让Canal在高并发场景下稳定运行

在日均千万级数据变更的高并发场景下,默认配置的Canal可能会出现同步延迟、TPS不足、内存溢出等问题。通过以下优化手段,可以显著提升Canal的性能与稳定性,实现TPS提升150%、同步延迟控制在1秒内的效果。

5.1 服务端参数调优(核心)

修改Canal Server的canal.properties和instance.properties配置文件,重点优化以下参数:

5.1.1 网络IO优化
java 复制代码
# 启用Netty零拷贝机制,提升网络传输效率
canal.server.socket.sndbufsize=65536
canal.server.socket.rcvbufsize=65536
# 禁用Nagle算法,降低网络延迟
canal.server.tcp.no.delay=true
# 开启TCP长连接,避免频繁建立连接
canal.server.socket.keepalive=true
# 批量发送配置(关键):累积1024条或等待200ms后批量发送
canal.server.batch.size=1024
canal.server.batch.timeout=200

优化效果:网络IO次数降低90%,延迟显著降低。

5.1.2 内存与解析优化
java 复制代码
# 增大内存缓冲区(默认16MB,优化为256MB)
canal.instance.memory.buffer.size=16384
canal.instance.memory.buffer.memunit=KB
# 启用批量内存模式
canal.instance.memory.batch.mode=true
# 启用并行解析,提升Binlog解析效率
canal.instance.parser.parallel=true
canal.instance.parser.parallelThreadSize=4  # 并行线程数(建议等于CPU核心数)

优化效果:单实例峰值处理能力从3000行/秒提升至10000行/秒,避免频繁GC。

5.1.3 数据库连接优化
java 复制代码
# 优化MySQL连接池配置
canal.instance.dbcp2.maxTotal=32  # 最大连接数(建议为CPU核心数的4倍)
canal.instance.dbcp2.maxIdle=8
canal.instance.dbcp2.minIdle=4
canal.instance.dbcp2.testOnBorrow=true  # 获取连接时验证有效性
# 启用Binlog缓存,减少与MySQL的交互次数
canal.instance.binlogCacheSize=8192  # 8MB缓存

5.2 客户端消费优化

  1. 批量消费:客户端采用批量获取消息(getWithoutAck(100, 1000))和批量ACK的方式,减少与Canal Server的交互次数;

  2. 异步处理:将数据同步逻辑(如写入Redis/ES)改为异步处理,避免同步操作阻塞消费线程;

  3. 线程池隔离:为不同表的同步任务分配独立的线程池,避免单表大量变更阻塞其他表的同步。

5.3 架构层面优化

  1. 多实例部署:将不同数据库或不同表的同步任务拆分到多个Canal实例,避免单实例负载过高;

  2. 集群高可用:基于ZooKeeper部署Canal Server集群,实现实例故障自动切换,避免单点故障;

  3. 引入消息中间件:高吞吐场景下,将Canal的数据投递模式改为Kafka/RocketMQ,通过消息队列削峰填谷,提升系统稳定性。

5.4 性能监控体系

优化的前提是可监控,Canal支持Prometheus指标暴露,通过以下配置开启后,可接入Grafana进行可视化监控:

java 复制代码
# canal.properties中开启Prometheus监控
canal.metrics.prometheus=true
canal.metrics.prometheus.port=9091

核心监控指标(需重点关注):

指标名称 含义说明 阈值范围 优化优先级
canal_instance_tps 实例处理事务数/秒 <5000 → 需优化 P0
canal_instance_delay 同步延迟时间(ms) >1000 → 严重 P0
canal_store_used_memory 内存存储占用(MB) >2048 → 高风险 P1

六、架构演进:从单机工具到云原生平台

Canal自开源以来,经历了从单机工具到云原生平台的跨越式演进,尤其是1.1.x版本系列,引入了诸多云原生特性,使其能够更好地适配分布式云环境。

6.1 早期单机架构的局限性

Canal早期版本采用典型的单体架构,核心组件(Binlog解析器、内存存储、TCP服务端)耦合在一起,存在三大痛点:

  • 单点故障风险:Canal Server单点部署,一旦宕机,整个数据同步链路中断;

  • 配置管理复杂:实例配置通过本地文件管理,集群环境下同步配置困难,运维成本高;

  • 水平扩展受限:无法动态调整计算资源,面对流量波动时响应能力不足。

6.2 1.1.x版本的架构革新

Canal 1.1.x系列通过三大核心升级,实现了向云原生的转型:

6.2.1 组件化与解耦

将Binlog解析、数据转换、网络传输等核心功能拆分为独立模块,引入可插拔的适配器模式,支持自定义数据转换器和输出适配器(原生支持Kafka/RocketMQ/TCP等多种投递方式),灵活性大幅提升。

6.2.2 云原生部署支持

提供完整的Docker镜像和Helm Chart支持,可快速部署到Kubernetes集群中,实现容器化编排与弹性伸缩。例如,通过Kubernetes HPA(Horizontal Pod Autoscaler)配置,可根据CPU和内存使用率自动扩缩容Canal Server实例:

java 复制代码
autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80
6.2.3 引入Canal Admin组件

1.1.4版本引入的Canal Admin,带来了革命性的运维体验:

  • Web UI可视化管理:通过浏览器即可完成实例配置、启动/停止、监控等操作;

  • 动态实例部署:支持在线创建、修改实例配置,无需重启Canal Server;

  • 多集群统一管理:支持对多个Canal Server集群进行集中监控与告警;

  • 配置版本控制:支持配置的历史版本回溯,避免误操作。

七、总结与展望

Canal作为一款成熟的MySQL增量数据同步工具,凭借其"无侵入、低延迟、高可靠"的核心特性,已成为分布式架构中的关键基础设施。从原理上看,它巧妙地利用了MySQL主从复制机制,实现了对增量数据的精准捕获;从实操上看,其部署简单、客户端开发便捷,能够快速对接各类下游系统;从场景上看,它覆盖了缓存同步、检索引擎同步、监控审计、数据备份等多个核心业务场景;从演进上看,它正朝着云原生、高可用、可扩展的方向持续发展。

对于开发者而言,掌握Canal不仅能够解决日常工作中的数据同步难题,更能深入理解MySQL主从复制、Binlog解析等底层技术原理。未来,随着多数据源支持(如PostgreSQL、Oracle)、云原生能力的进一步增强,Canal的应用场景将更加广泛,成为实时数据架构中的核心枢纽。

🌟 关注【予枫】,获取更多技术干货

  • 📅 身份:一名热爱技术的研二学生

  • 🏷️ 标签:Java / 算法 / 个人成长

  • 💬 Slogan:只写对自己和他人有用的文字。

相关推荐
专家大圣1 小时前
Tomcat+cpolar 让 Java Web 应用跨越局域网随时随地可访问
java·前端·网络·tomcat·内网穿透·cpolar
Filotimo_1 小时前
在java后端开发中,LEFT JOIN的用法
java·开发语言·windows
Swift社区1 小时前
在Swift中实现允许重复的O(1)随机集合
开发语言·ios·swift
承渊政道1 小时前
C++学习之旅【C++Vector类介绍—入门指南与核心概念解析】
c语言·开发语言·c++·学习·visual studio
2301_797312261 小时前
学习Java43天
java·开发语言
程序员老徐2 小时前
Spring Security 是如何注入 Tomcat Filter 链的 —— 启动与请求源码分析
java·spring·tomcat
冰暮流星2 小时前
javascript之do-while循环
开发语言·javascript·ecmascript
2501_944424123 小时前
Flutter for OpenHarmony游戏集合App实战之连连看路径连线
android·开发语言·前端·javascript·flutter·游戏·php
C系语言4 小时前
python用pip生成requirements.txt
开发语言·python·pip