关于kafka-connect的一些理解
大家好,我是一拳就能打爆A柱的一拳超人
这个礼拜我去了解了kafka-connect,相信各位应该了解过Confluent,也可能用过一些功能。我之前使用Confluent是因为这个平台可以做自定义数据拉取,通过配置可以对许多数据源的数据做增量查询。但是经过这段时间的了解,我发现其实并不需要confluent平台,只需要其中的一个组件,也就是kafka-connect。kafka-connect-jdbc是基于kafka-connect开源项目开发出来的可以适配所有JDBC数据库的一个组件。接下来我将从kafka-connect的核心组件、kafka-connect-jdbc源码阅读两个方面给大家介绍我的收获。
1、kafka-connect的几个核心组件
在查找kafka-connect-jdbc相关的博客的过程中,我了解到kafka-connect-jdbc其实是在kafka-connect这个开源组件的基础上开发出来的。所以我决定先去了解kafka-connect的内容,在JDBC Connect (Source and Sink)中有一些功能性的介绍,但是并没有内部组件的介绍。最后我在博客confluent kafka-connect-jdbc中找到其核心组件的介绍,kafka-connect-jdbc核心组件有七个:
- source:负责将外部数据写入kafka的topic中。
- sink: 负责从kafka中读取数据到自己需要的地方去,比如读取到HDFS,hbase等;可以接收数据,也可以接收模式信息 。
- connectors: 通过管理任务来协调数据流的高级抽象。
- Tasks: 数据写入kafka和从kafka中读出数据的具体实现,source和sink使用时都需要Task。
- Workers: 运行connectors和tasks的进程 。
- Converters: kafka connect转换器提供了一种机制,用于将数据从kafka connect使用的内部数据类型转换为表示为Avro、Protobuf或JSON模式的数据类型 。
- Transforms: 一种轻量级数据调整的工具 。
在Confluent对Kafka Connect的介绍中提到Kafka Connect是Apache Kafka的免费开源组件,并且在文章《Kafka Connect介绍以及在分布式部署模式下启动流程分析》中有介绍:
Kafka背后的公司Confluent鼓励社区创建更多的开源的Connector,将Kafka生态圈扩大,促进Kafka Connect的应用。 另外在Kafka Connect框架的支持下,我们还可自定义开发Connector,关于如何开发一个定制的Connector,可参考《Connector Developer Guide》。
也就是说,开发者可以在Kafka Connect的基础做定制的Connector,从而将各种数据源的数据连接到Kafka。所以我接下来的目标是了解Kafka Connect的开发,从而理解kafka-connect-jdbc的设计原理。
由于Confluent的官方开发指南不是很好看,所以我找到一篇翻译版的博客《Kafka Connector开发指南》,指南中以Kafka Connect自带的本地文件Connector为案例简单介绍了connector编程入口以及各部分的功能。首先,kafka connect已经提供了很强大的支持,开发者只需要实现SourceConnector、SinkConnector、SourceTask和SinkTask即可。SourceConnector与SourceTask是一对,Connector是编程的入口(无论是Souce还是Sink),SouceConnector负责完成properties的配置和Task的定义和配置。
在SourceConnector中需要实现如下几个方法:
- public ConfigDef config() : this is how we expose what properties the connector cares about
- public void start(Map<String, String> props):Connector的生命周期中第一个被调用的方法,也是开发者设置kafka connect属性的地方。
- public Class<? extends Task> taskClass():返回值代表Task应当对应的Connector。
- public List<Map<String, String>> taskConfigs(int maxTasks):定义Task的扩展方式以及每个Task应具备的配置。
- public void stop():在程序关闭或者崩溃时被调用,即生命周期结束的收尾函数。
在SourceTask中需要实现如下几个方法:
- public void start(Map<String, String> props):与Connector类似,Task在start方法(生命周期第一个函数)中做参数配置。
- public List poll() throws InterruptedException:poll方法是实际工作的方法,kafka会尽可能快地循环调用该方法。在这个方法中,开发者需要链接外部系统拉取数据,整理成SourceRecords列表返回给Connect框架。
- public void stop():与Connector相同,生命周期结束的收尾函数。
2、阅读kafka-connect-jdbc源码
2.1 SourceConnector
根据kafka-connect-jdbc的 JdbcSourceConnector我画出了下面这张图:
经观察发现Connector的主要工作都将交由这几部分来完成:
- JdbcSourceConnectorConfig 负责Connector可能需要的所有参数的定义。
- CachedConnectionProvider 负责保存并提供JDBC的Connection。
- TableMonitorThread 负责对whitelist中的表做监视。
- DatabaseDialect 负责数据库方言相关的工作。
在Connector生命周期中的第一个函数start函数中,除了将properties(由其他组件自动解析成map送入connector的start方法)中的参数取出设置相关参数外,还进行了以上几个类的设置:
// 数据库方言
dialect = DatabaseDialects.findBestFor(
dbUrl,
config
);
// 建立数据库连接
cachedConnectionProvider = connectionProvider(maxConnectionAttempts, connectionRetryBackoff);
// 开启表监视线程
tableMonitorThread = new TableMonitorThread(
dialect,
cachedConnectionProvider,
context,
tablePollMs,
whitelistSet,
blacklistSet
);
tableMonitorThread.start();
经过start函数的设置,连接已经打开并且已经有线程负责监视表的变化了。在SourceConnector中还有一个比较长的函数taskConfigs,在这个函数中对SouceConnectorConfig的子类SourceTaskConfig添加了一些配置。
2.2 SourceTask
同样的对SourceTask我也画了相应的图:
在Task的start方法中,除了必要的参数的设置以及重要对象的构建(如cachedConnectionProvider、各种Querier),还对每张表的不同协议下的偏移量做了计算。
2.2.1 start
start函数的职责同样是做一些准备工作。首先,需要计算表在不同协议下的partition:
// 定义table和partition的map,list中装的是不同协议对应partition的map
Map<String, List<Map<String, String>>> partitionsByTableFqn = new HashMap<>();
// 经过一系列的验证参数后,对所有的表做循环,将不同表下不同协议下对应的partition都记录下来
for (String table : tables) {
// Find possible partition maps for different offset protocols
// We need to search by all offset protocol partition keys to support compatibility
List<Map<String, String>> tablePartitions = possibleTablePartitions(table);
partitions.addAll(tablePartitions);
partitionsByTableFqn.put(table, tablePartitions);
}
然后,在得到不同表下不同协议下对应的partition后,循环遍历每张表,计算出每张表在不同协议下的偏移量:
for (String tableOrQuery : tablesOrQuery) {
final List<Map<String, String>> tablePartitionsToCheck;//装的是当前表在不同协议对应的分区
final Map<String, String> partition;
switch (queryMode) {
case TABLE: // 在预设好的表查询模式(incrementing等)的情况,做参数校验
if (validateNonNulls) {
validateNonNullable(
mode,
tableOrQuery,
incrementingColumn,
timestampColumns
);
}
// 得到表对应的List<Map<协议,partition>>
tablePartitionsToCheck = partitionsByTableFqn.get(tableOrQuery);
break;
case QUERY: // 对自定义表查询模式,做相应的校验
...
default:
throw new ConnectException("Unexpected query mode: " + queryMode);
}
Map<String, Object> offset = null;
if (offsets != null) {
// 取出Map<协议,partition>
for (Map<String, String> toCheckPartition : tablePartitionsToCheck) {
// 取出协议A,partitionA下的偏移量
// 而offsets是在context中保存的
offset = offsets.get(toCheckPartition);
if (offset != null) { // 能取出偏移量则协议正确
log.info("Found offset {} for partition {}", offsets, toCheckPartition);
break;
}
}
}
offset = computeInitialOffset(tableOrQuery, offset, timeZone); // 再次校验偏移量
...
// 根据不同的查询模式添加查询器,并且其顺序跟table在List中顺序相同,故一一对应
if (mode.equals(JdbcSourceTaskConfig.MODE_BULK)) {
tableQueue.add(
new BulkTableQuerier(
dialect,
queryMode,
tableOrQuery,
topicPrefix,
suffix
)
);
} else if (mode.equals(JdbcSourceTaskConfig.MODE_INCREMENTING)) {
...
} else if (mode.equals(JdbcSourceTaskConfig.MODE_TIMESTAMP)) {
...
} else if (mode.endsWith(JdbcSourceTaskConfig.MODE_TIMESTAMP_INCREMENTING)) {
...
}
}
上面这段代码的最终效果是通过所有表在不同协议下的partition计算出对应的偏移量,并且根据方言、查询模式偏移量等构造出查询器,通过查询器可进行后面的查询工作。
2.2.2 poll
poll的职责是做实际的数据拉取工作,通过查询start中配置好的querier来做数据的拉取,将结果放在List< SourceRecord>中集中返回给上层。由于poll函数会被connect高速反复地调用且查询器队列保存了查询顺序,所以实现对多个表的轮询。以下是对poll关键步骤的注释:
@Override
public List<SourceRecord> poll() throws InterruptedException {
// 目前还不清楚这个对象作用
Map<TableQuerier, Integer> consecutiveEmptyResults = tableQueue.stream().collect(
Collectors.toMap(Function.identity(), (q) -> 0));
while (running.get()) {
// 取出查询器
final TableQuerier querier = tableQueue.peek();
....
// 查询到的结果集
final List<SourceRecord> results = new ArrayList<>();
try {
log.debug("Checking for next block of results from {}", querier.toString());
querier.maybeStartQuery(cachedConnectionProvider.getConnection());
int batchMaxRows = config.getInt(JdbcSourceTaskConfig.BATCH_MAX_ROWS_CONFIG);
boolean hadNext = true;
// 将结果逐条查询并添加入结果集
while (results.size() < batchMaxRows && (hadNext = querier.next())) {
results.add(querier.extractRecord());
}
// 无法查到下一条后,将该查询器加入队列尾部
if (!hadNext) {
resetAndRequeueHead(querier);
}
if (results.isEmpty()) {
// 对结果集在空集合下做处理
...
} else {
consecutiveEmptyResults.put(querier, 0);
}
// 将结果返回给上层
return results;
} catch (SQLException sqle) {
...
} catch (Throwable t) {
...
}
}
// Only in case of shutdown
final TableQuerier querier = tableQueue.peek();
if (querier != null) {
resetAndRequeueHead(querier);
}
closeResources();
return null;
}
经过上面Task的两个关键方法以及Connector的分析,我了解到kafka-connect-jdbc两个核心实现类的工作流程。
总结
经过对kafka-connect-jdbc的学习,对kafka-connect的概念以及各组件有了一些了解。最后通过阅读kafka-connect-jdbc源码的Connector和Task实现类了解到其内部的运行机制。
kafka-connect-jdbc是针对所有jdbc数据库开发的连接器,他在不同模块都做了相应的抽象,最重要的是查询器(querier)以及方言(dialect),程序运行之后通过Connector对参数进行配置、校验并且做表监视,而Task则通过计算分区、偏移量进行下一步的数据拉取。而数据拉取的工作是交给querier来完成的,其背后关系到方言的转换、SQL的创建。