Debezium实现MySQL数据监听

  • 了解Debezium
  • ​ 本期主要内容
  • 实现步骤
    • 1. 新建Maven工程
    • 2.导入依赖
    • 3.核心代码编写
    • 4.offset的存储
    • 5.OffsetBackingStore实现jdbc模式
    • 6.运行结果
  • 总结

了解Debezium

官网:https://debezium.io/
Debezium是一组分布式服务,用于捕获数据库中的更改,以便应用程序可以看到这些更改并对其做出响应。Debezium在更改事件流中记录每个数据库表中的所有行级更改,应用程序只需读取这些流,以按更改事件发生的相同顺序查看更改事件。
简单来说,Debezium可以用来捕获变更数据,包括表结构or表数据的增删改,并将这些变更数据流式传递到下游,以便做进一步的操作。flink便是在此基础上实现的,但flink成本比较高昂。

​ 本期主要内容

实现MySQL binlog数据监听功能:

  • 支持无状态的全量、增量同步和有状态的全量、增量同步功能
  • 通过自定义JdbcOffsetBackingStore将offset存储到数据库

实现步骤

这里使用Debezium 3.2.0.Final 版本进行测试。

1. 新建Maven工程

新建一个java项目,选择Maven构建,大家都是老司机,这里就不在赘述!

2.导入依赖

		<!-- Debezium核心库 --><dependency><groupId>io.debezium</groupId><artifactId>debezium-embedded</artifactId><version>3.2.0.Final</version></dependency><!-- MySQL连接器 --><dependency><groupId>io.debezium</groupId><artifactId>debezium-connector-mysql</artifactId><version>3.2.0.Final</version></dependency><!-- 数据库驱动 (MySQL 8.0+) --><dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><version>8.2.0</version></dependency><dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>2.0.52</version></dependency>

3.核心代码编写

import com.alibaba.fastjson2.JSONObject;
import io.debezium.engine.ChangeEvent;
import io.debezium.engine.DebeziumEngine;
import io.debezium.engine.format.Json;import java.io.IOException;
import java.util.Objects;
import java.util.Properties;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;/*** @author lzq*/
public class DebeziumMysqlExample {public static void main(String[] args) {// 1. 配置Debezium连接器属性Properties props = configureDebeziumProperties();// 2. 创建Debezium引擎DebeziumEngine<ChangeEvent<String, String>> engine = DebeziumEngine.create(Json.class).using(props).notifying(DebeziumMysqlExample::processRecords).build();// 3. 启动引擎ExecutorService executor = Executors.newSingleThreadExecutor();executor.execute(engine);// 4. 注册关闭钩子,优雅退出Runtime.getRuntime().addShutdownHook(new Thread(() -> {try {System.out.println("正在关闭Debezium引擎...");engine.close();executor.shutdown();} catch (IOException e) {throw new RuntimeException(e);}}));}/*** 处理捕获到的变更事件*/private static void processRecords(ChangeEvent<String, String> record) {String value = record.value();System.out.println("捕获到变更事件 :" + value);try {if (value == null) {return;}JSONObject from = JSONObject.parse(value);JSONObject before = from.getJSONObject("before");JSONObject after = from.getJSONObject("after");String ddl = from.getString("ddl");// 操作类型 op: r(读取) c(创建), u(更新), d(删除)System.out.println("++++++++++++++++++++++++ MySQL Binlog Change Event ++++++++++++++++++++++++");System.out.println("op: " + from.getString("op"));System.out.println("change before: " + (Objects.nonNull(before) ? before.toJSONString() : "无"));System.out.println("change after: " + (Objects.nonNull(after) ? after.toJSONString() : "无"));System.out.println("ddl: " + (Objects.nonNull(ddl) ? ddl : "无"));} catch (Exception e) {e.printStackTrace();}}
}

最最重要的Debezium属性配置

   /*** 配置Debezium连接器属性* */private static Properties configureDebeziumProperties() {Properties props = new Properties();// 连接器基本配置 // name可以是任务名称,任务启动后会生成相同名称的线程名props.setProperty("name", "mysql-connector");//必填项,指定topic名称前缀,虽然这里没用到kafka, 但是必须配置,否则会报错props.setProperty("topic.prefix", "bbb-");// mysql连接器全限定名,其他数据库类型时需要更换props.setProperty("connector.class", "io.debezium.connector.mysql.MySqlConnector");// 要监听的数据库连接信息props.setProperty("database.hostname", "localhost");props.setProperty("database.user", "debezium");props.setProperty("database.password", "123456");props.setProperty("database.port", "3306");//伪装成mysql从服务器的唯一Id,serverId冲突会导致其他进程被挤掉props.setProperty("database.server.id", "184055");props.setProperty("database.server.name", "mysql-server");// 监听的数据库props.setProperty("database.include.list", "course_db");// 监听的表props.setProperty("table.include.list", "course_db.course_2");// 快照模式// initial(历史+增量)// initial_only(仅读历史)// no_data(同schema_only) 仅增量,读取表结构的历史和捕获增量,以及表数据的增量// schema_only_recovery(同recovery) 从指定offset恢复读取,暂未实现props.setProperty("snapshot.mode", "initial");// 偏移量刷新间隔props.setProperty("offset.flush.interval.ms", "5000");// 偏移量存储 文件props.setProperty("offset.storage", "org.apache.kafka.connect.storage.FileOffsetBackingStore");// 会在项目目录下生成文件props.setProperty("offset.storage.file.filename", "mysql-offset.dat");// 是否包含数据库表结构层面的变更 默认值trueprops.setProperty("include.schema.changes", "false");// 是否仅监听指定表的ddl变更 默认值false, false会监听所有schema的表结构变更props.setProperty("schema.history.internal.store.only.captured.tables.ddl", "true");// 表结构历史存储(可选)props.setProperty("schema.history.internal", "io.debezium.storage.file.history.FileSchemaHistory");props.setProperty("schema.history.internal.file.filename", "schema-history.dat");// Debezium 3.2新特性: 启用新的记录格式props.setProperty("value.converter", "org.apache.kafka.connect.json.JsonConverter");props.setProperty("value.converter.schemas.enable", "false");return props;}

4.offset的存储

官网对于offset store 的介绍提供了这些实现讲解,但connect-runtime包中只提供了前3种实现分别是kafkafilememory
其中file方式适用于单机版和测试场景;memory方式适用于测试场景或短期任务,不适用生产环境;而kafka方式存在局限性,本人在使用过程中发现创建的topic 必须配置清除策略为压缩(即cleanup.policy=compact),而公司使用的是阿里云的kafka,暂无法创建压缩策略的topic。
所以这几种方式都无法满足生产要求,只能另谋他路,于是决定自己实现数据库存储offset。

在这里插入图片描述

5.OffsetBackingStore实现jdbc模式

我们看到file/kafka/memory 最终都实现了 OffsetBackingStore,所以我们也需要实现它来编写我们的 JdbcOffsetBackingStore。
这里实现逻辑是多个task共用一张表来存储offset,每个任务只存一条offset数据,其中上述配置中的name和topic.prefix不同,生成的offset_key 就不同,配置时要注意不要配置相同的name和topic.prefix的组合值。
OffsetBackingStore的实现类
废话不多说,上代码

package com.kw.debzium.debeziumdemo.debez.storege;import com.md.util.Snowflake;
import org.apache.commons.lang3.StringUtils;
import org.apache.kafka.connect.errors.ConnectException;
import org.apache.kafka.connect.runtime.WorkerConfig;
import org.apache.kafka.connect.storage.OffsetBackingStore;
import org.apache.kafka.connect.util.Callback;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;/*** @author lzq*/
public class JdbcOffsetBackingStore implements OffsetBackingStore {private static final Logger log = LoggerFactory.getLogger(JdbcOffsetBackingStore.class);//这里使用雪花算法生成表主键Id,可根据实际情况调整public static final Snowflake SNOWFLAKE = new Snowflake(1, 1);private String tableName;private Connection connection;@Overridepublic void start() {log.info("Starting JdbcOffsetBackingStore");try {// 创建表结构createOffsetTableIfNotExists();} catch (SQLException e) {throw new ConnectException("Failed to start JdbcOffsetBackingStore", e);}}@Overridepublic void stop() {log.info("Stopping JdbcOffsetBackingStore");if (connection != null) {try {connection.close();} catch (SQLException e) {log.warn("Error while closing JDBC connection", e);}}}@Overridepublic Future<Map<ByteBuffer, ByteBuffer>> get(Collection<ByteBuffer> keys) {return CompletableFuture.supplyAsync(() -> {Map<ByteBuffer, ByteBuffer> result = new HashMap<>();for (ByteBuffer key : keys) {ByteBuffer value = getOffset(key);if (value != null) {result.put(key, value);}}return result;});}@Overridepublic Future<Void> set(Map<ByteBuffer, ByteBuffer> values, Callback<Void> callback) {return CompletableFuture.runAsync(() -> {for (Map.Entry<ByteBuffer, ByteBuffer> entry : values.entrySet()) {setOffset(entry.getKey(), entry.getValue());}if (callback != null) {callback.onCompletion(null, null);}});}@Overridepublic Set<Map<String, Object>> connectorPartitions(String connectorName) {return Set.of();}@Overridepublic void configure(WorkerConfig config) {Map<String, Object> originals = config.originals();String jdbcUrl = (String) originals.getOrDefault(JdbcWorkerConfig.OFFSET_STORAGE_JDBC_URL_CONFIG, "");String username = (String) originals.getOrDefault(JdbcWorkerConfig.OFFSET_STORAGE_JDBC_USER_CONFIG, "");String password = (String) originals.getOrDefault(JdbcWorkerConfig.OFFSET_STORAGE_JDBC_PASSWORD_CONFIG, "");tableName = (String) originals.getOrDefault(JdbcWorkerConfig.OFFSET_STORAGE_JDBC_TABLE_NAME_CONFIG, "");try {// 建立数据库连接connection = DriverManager.getConnection(jdbcUrl, username, password);} catch (SQLException e) {throw new ConnectException("Failed to configure JdbcOffsetBackingStore", e);}}/*** 创建offset存储表(如果不存在)** @throws SQLException SQL执行异常*/private void createOffsetTableIfNotExists() throws SQLException {String createTableSQL = String.format("CREATE TABLE IF NOT EXISTS %s (" +"id BIGINT(20)      NOT NULL primary key ," +"offset_key          VARCHAR(1255)," +"offset_val          VARCHAR(1255)," +"record_insert_ts    TIMESTAMP NOT NULL" +")", tableName);try (PreparedStatement stmt = connection.prepareStatement(createTableSQL)) {stmt.execute();}}/*** 从数据库获取指定key的offset值** @param key 键* @return 对应的offset值*/private ByteBuffer getOffset(ByteBuffer key) {String keyStr = bytesToString(key);String selectSQL = String.format("SELECT offset_val FROM %s WHERE offset_key = ? ORDER BY record_insert_ts desc limit 1", tableName);try (PreparedStatement stmt = connection.prepareStatement(selectSQL)) {stmt.setString(1, keyStr);ResultSet rs = stmt.executeQuery();if (rs.next()) {String valueStr = rs.getString(1);return StringUtils.isNotBlank(valueStr) ? ByteBuffer.wrap(valueStr.getBytes()) : null;}} catch (SQLException e) {log.error("Error getting offset for key: {}", keyStr, e);}return null;}/*** 将offset值存储到数据库* 这里插入和删除没有添加事务,是因为我这边想用一张表来存储多个task的 offset,测试当两个task同时删除数据时,会导致后者删除的操作失败* 在取数据时是按时间倒排,取最新一条,所以某次删除失败不影响最终结果* @param key   键* @param value 值*/private void setOffset(ByteBuffer key, ByteBuffer value) {if (Objects.isNull(key) || Objects.isNull(value)) {return;}String keyStr = bytesToString(key);byte[] valueBytes = value.array();String valueStr = new String(valueBytes, StandardCharsets.UTF_8);try {// 插入新的offsetinsertNewOffset(keyStr, valueStr);// 删除最旧的offsetdeleteOldestOffsetIfNeeded(keyStr);log.info("Offset stored success for key: {}, value: {}", keyStr, valueStr);} catch (SQLException e) {log.error("Error setting offset for key: {}", keyStr, e);}}private void insertNewOffset(String keyStr, String valueStr) throws SQLException {String upsertSQL = String.format("INSERT INTO %s(id, offset_key, offset_val, record_insert_ts) VALUES ( ?, ?, ?, ? )",tableName);try (PreparedStatement stmt = connection.prepareStatement(upsertSQL)) {long id = SNOWFLAKE.nextId();stmt.setLong(1, id);stmt.setString(2, keyStr);stmt.setString(3, valueStr);stmt.setTimestamp(4, Timestamp.from(Instant.now()));stmt.executeUpdate();}}private void deleteOldestOffsetIfNeeded(String keyStr) {//count > 2 执行删除最旧的offsetint count = 0;try (PreparedStatement stmt = connection.prepareStatement("SELECT COUNT(*) FROM " + tableName + " WHERE offset_key = ?")) {stmt.setString(1, keyStr);ResultSet rs = stmt.executeQuery();if (rs.next()) {count = rs.getInt(1);}} catch (SQLException e) {log.error("Error counting offsets", e);}if (count > 1) {String deleteSQL = String.format("DELETE FROM %s WHERE offset_key = ? " +"AND id < (SELECT * FROM (SELECT MAX(id) FROM %s WHERE offset_key = ?) AS tmp)",tableName, tableName);try (PreparedStatement stmt = connection.prepareStatement(deleteSQL)) {stmt.setString(1, keyStr);stmt.setString(2, keyStr);int deletedRows = stmt.executeUpdate();if (deletedRows > 0) {log.info("Deleted oldest offset");}} catch (SQLException e) {log.error("Error deleting oldest offset", e);}}}/*** 将ByteBuffer转换为字符串表示** @param buffer ByteBuffer对象* @return 字符串表示*/private String bytesToString(ByteBuffer buffer) {if (Objects.isNull(buffer)) {return null;}byte[] bytes = new byte[buffer.remaining()];buffer.duplicate().get(bytes);return new String(bytes);}
}

配置类继承WorkerConfig

import org.apache.kafka.common.config.ConfigDef;
import org.apache.kafka.connect.runtime.WorkerConfig;import java.util.Map;/*** Worker config for JdbcOffsetBackingStore* @author lzq*/
public class JdbcWorkerConfig extends WorkerConfig {private static final ConfigDef CONFIG;/*** The jdbc info of the offset storage jdbc.*/public static final String OFFSET_STORAGE_JDBC_URL_CONFIG = "offset.storage.jdbc.connection.url";private static final String OFFSET_STORAGE_JDBC_URL_DOC = "database to store source connector offsets";public static final String OFFSET_STORAGE_JDBC_USER_CONFIG = "offset.storage.jdbc.connection.user";private static final String OFFSET_STORAGE_JDBC_USER_DOC = "database of user to store source connector offsets";public static final String OFFSET_STORAGE_JDBC_PASSWORD_CONFIG = "offset.storage.jdbc.connection.password";private static final String OFFSET_STORAGE_JDBC_PASSWORD_DOC = "database of password to store source connector offsets";public static final String OFFSET_STORAGE_JDBC_TABLE_NAME_CONFIG = "offset.storage.jdbc.table.name";private static final String OFFSET_STORAGE_JDBC_TABLE_NAME_DOC = "table name to store source connector offsets";static {CONFIG = baseConfigDef().define(OFFSET_STORAGE_JDBC_URL_CONFIG,ConfigDef.Type.STRING,ConfigDef.Importance.HIGH,OFFSET_STORAGE_JDBC_URL_DOC).define(OFFSET_STORAGE_JDBC_USER_CONFIG,ConfigDef.Type.STRING,ConfigDef.Importance.HIGH,OFFSET_STORAGE_JDBC_USER_DOC).define(OFFSET_STORAGE_JDBC_PASSWORD_CONFIG,ConfigDef.Type.STRING,ConfigDef.Importance.HIGH,OFFSET_STORAGE_JDBC_PASSWORD_DOC).define(OFFSET_STORAGE_JDBC_TABLE_NAME_CONFIG,ConfigDef.Type.STRING,ConfigDef.Importance.HIGH,OFFSET_STORAGE_JDBC_TABLE_NAME_DOC);}public JdbcWorkerConfig(Map<String, String> props) {super(CONFIG, props);}
}

6.运行结果

去掉文件offset配置,将offset.storage配置调整为

    // 偏移量存储 数据库 包路径、数据库信息改成自己的props.setProperty("offset.storage", "com.kw.debzium.debeziumdemo.storege.JdbcOffsetBackingStore");props.setProperty("offset.storage.jdbc.connection.url", "jdbc:mysql://localhost:3306/course_db");props.setProperty("offset.storage.jdbc.connection.user", "debezium");props.setProperty("offset.storage.jdbc.connection.password", "123456");props.setProperty("offset.storage.jdbc.table.name", "course_db.tbl_offset_storage");

在这里插入图片描述

在这里插入图片描述
这里是两个任务只有 name和topic.prefix 、server.id不同,一个main方法,监听的同一个表,启了两次,所以pos ,gtids是一样的.

重启任务,发现已从数据库加载到了上次消费到的offset记录
在这里插入图片描述
至此已实现断点续传功能!

总结

根据官网说明,通过一些配置,即可实现从mysql binlog中监听数据,并实时打印数据变更结果。根据offset实现状态存储到数据库,最终实现断点续传功能。
接下来,将结合spring boot 实现多任务启动,并实现sink到kafka or db ,敬请期待!

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

如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

InfluxDB 存储优化:TSM 文件管理与空间回收(一)

一、InfluxDB 与 TSM 文件初相识**在数字化时代&#xff0c;数据量呈爆发式增长&#xff0c;尤其是时间序列数据&#xff0c;如服务器监控指标、传感器读数、金融交易记录等&#xff0c;它们都带有时间戳&#xff0c;记录着事物随时间的变化。InfluxDB 作为一款高性能的开源时序…

macos使用FFmpeg与SDL解码并播放H.265视频

效果: 安装依赖: brew install ffmpeg brew install sdl2 brew install x265 确认x265已启用 查看x265版本 工程CMakeLists.txt

C#开源库ACadSharp读取dwg图元的示例

文章目录介绍数据示例读取图元属性介绍 开源库ACadSharp的地址&#xff1a;https://github.com/DomCR/ACadSharp 可以在NuGet中搜索到该库并安装。 数据示例 数据是一个绘制了以下简单图元的dwg数据&#xff1a; 读取图元属性 创建了.net6控制台项目&#xff0c;通过NuG…

【UniApp打包鸿蒙APP全流程】如何配置并添加UniApp API所需的鸿蒙系统权限

一、前言&#xff1a;为什么选择 UniApp 打包鸿蒙应用&#xff1f; 随着鸿蒙生态的快速发展&#xff0c;越来越多开发者希望将现有跨平台项目快速接入鸿蒙系统。而 UniApp 作为国内领先的跨平台开发框架&#xff0c;凭借其“一次开发&#xff0c;多端发布”的特性&#xff0c;…

STM32-FreeRTOS快速入门指南(下)

第十一章 FreeRTOS事件标志组 1. 事件标志组简介 事件标志组与信号量一样属于任务间同步的机制&#xff0c;但是信号量一般用于任务间的单事件同步&#xff0c;对于任务间的多事件同步&#xff0c;仅使用信号量就显得力不从心了。 FreeRTOS 提供的事件标志组可以很好的处理多事…

KTH7812磁编码器芯片完全支持ABZ和UVW输出模式

KTH7812磁编码器芯片完全支持ABZ和UVW输出模式&#xff0c;具体功能细节如下&#xff1a;&#x1f527; 1. ABZ输出特性 分辨率可编程&#xff1a;支持 4~4096步/圈&#xff08;对应1~1024个脉冲周期/圈&#xff09;&#xff0c;用户可通过配置寄存器自定义分辨率。 输出频率…

Android为ijkplayer设置音频发音类型usage

官方文档 多区音频路由 | Android Open Source Projecthttps://source.android.google.cn/docs/automotive/audio/audio-multizone-routing?hlzh-cn 背景 车机系统开发多分区&#xff08;zone&#xff09;功能&#xff0c;可以实现同一个app通过设置&#xff0c;在不同分…

C++ 循环:从入门到精通的深度解析

《C++ 循环:从入门到精通的深度解析》 目录 循环的本质与编程价值 三大基础循环结构详解 循环控制语句:break与continue的魔法 嵌套循环:构建复杂逻辑的基石 现代C++循环特性(C++11+) 循环性能优化与常见陷阱 实战案例:算法与工程中的循环应用 面试题深度解析与编程技巧…

| `cat /etc/os-release` | 发行版详细信息(如 Ubuntu、CentOS) |

在 Linux 或类 Unix 系统中&#xff0c;最简洁的命令查看操作系统类型是&#xff1a; uname -s✅ 输出示例&#xff1a; LinuxDarwin&#xff08;macOS&#xff09;FreeBSD 等&#x1f50d; 说明&#xff1a; uname&#xff1a;显示系统信息-s&#xff1a;仅显示操作系统内核名…

Maya 3D建模:点、线、面、创建多边面

目录 一 点、线、面 二 创建多边面 一 点、线、面 鼠标放在模型上 按住鼠标右键&#xff1a;就可以选择点 线 面 shift 加选点线面 ctrl 减选点线面 顶点面&#xff1a;是一个检查模式&#xff0c;观察有无错误 选择面&#xff0c;单击一个面&#xff0c;按住shift键 同时…

CXR-LT 2024:一场关于基于胸部X线的长尾、多标签和零样本疾病分类的MICCAI挑战赛|文献速递-深度学习人工智能医疗图像

Title题目CXR-LT 2024: A MICCAI challenge on long-tailed, multi-label, and zero-shotdisease classification from chest X-rayCXR-LT 2024&#xff1a;一场关于基于胸部X线的长尾、多标签和零样本疾病分类的MICCAI挑战赛01文献速递介绍CXR-LT系列是一项由社区推动的计划&a…

拆解本地组策略编辑器 (gpedit.msc) 的界面和功能

我们来详细拆解本地组策略编辑器 (gpedit.msc) 的界面和功能。打开后,你会看到一个标准的微软管理控制台 (MMC) 窗口,主要分为三个部分。 这是一个典型的本地组策略编辑器界面,我们将其分为三个主要部分进行讲解: +-----------------------------------------------+----…

[NCTF2019]True XML cookbook

TRY 尝试XML外部实体注入 <?xml version"1.0" encoding"utf-8" ?> <!DOCTYPE user[<!ENTITY flag SYSTEM "file://./doLogin.php"> ]> <user><username> &flag; </username><password>1</pa…

嵌入式硬件篇---模块使用

在电子开发、自动化控制等领域&#xff0c;“模块” 是实现特定功能的标准化组件&#xff08;可以理解为 “功能积木”&#xff09;。不同模块分工明确&#xff0c;比如有的负责感知环境&#xff08;传感器&#xff09;&#xff0c;有的负责通信&#xff08;蓝牙 / WiFi&#x…

密码管理中Null 密码

Null 密码定义&#xff1a;Null 密码是指允许用户或系统账户使用空密码&#xff08;即不输入任何字符&#xff09;进行登录或身份验证的配置。危害&#xff1a;完全绕过身份验证&#xff1a;这是最严重的危害。攻击者无需破解或窃取任何密码&#xff0c;只需输入用户名并留空密…

git新建项目如何推送到远程仓库

​ git新建项目如何推送到远程仓库 一、远程代码库操作(gitee为例) 1. 建新仓库 2. 找到地址:这里可以看到用户名等其他信息 3. 记住地址url(https) 二、本地操作 1. 安装git 2. 创建项目 3. 在当前项目下打开git bash 4. 添加远程仓库 5. 检查远程仓库地址 6. 检查当前状…

代码管理平台Gitlab如何通过 ZeroNews 实现远程访问?

Gitlab介绍1.1 GitLabGitLab 是一个基于 Web 的开源代码托管平台&#xff0c;集代码托管、项目管理、持续集成与持续部署等功能于一身。它采用 Git 作为版本控制系统&#xff0c;界面友好、功能丰富。相较于市场上的 Gitee 和 GitHub&#xff0c;GitLab 有以下优势&#xff1a;…

基于STM32F103C8T6控制A4988模块驱动2相4线步进电机

文章目录一、A4988模块简介二、A4988引脚说明三、A4988的Vref电压调节四、STM32F103C8T6控制A4988驱动2相4线步进电机准备工作引脚接线代码示例效果展示五、A4988电机驱动板常见问题一、A4988模块简介 A4988 是一款功能齐全的微步进电机驱动器&#xff0c;内置转换器&#xff0…

基于单片机智能晾衣架/智能窗户/智能窗帘设计

传送门 &#x1f449;&#x1f449;&#x1f449;&#x1f449;其他作品题目速选一览表 &#x1f449;&#x1f449;&#x1f449;&#x1f449;其他作品题目功能速览 概述 智能晾衣架系统基于单片机设计&#xff0c;融合传感器技术与物联网功能&#xff0c;实现衣物的自…

Python爬虫实战:研究dark-fantasy,构建奇幻文学数据采集分析系统

1. 引言 1.1 研究背景 奇幻文学作为奇幻文学的重要子类别,融合了哥特式元素与传统奇幻设定,以其对人性的深刻探索和复杂的道德困境构建,成为当代文学研究的重要对象。与传统奇幻文学强调英雄主义和光明战胜黑暗的叙事不同,奇幻往往展现道德边界、复杂的角色动机和充满不确…