This is an automated email from the ASF dual-hosted git repository.
renqs pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git
The following commit(s) were added to refs/heads/master by this push:
new 253ef9256 [pipeline-connector][kafka] Add kafka pipeline data sink
connector. (#2938)
253ef9256 is described below
commit 253ef9256bfef23a76d6c63d94b67685e326043e
Author: Kunni <[email protected]>
AuthorDate: Tue Apr 23 18:00:36 2024 +0800
[pipeline-connector][kafka] Add kafka pipeline data sink connector. (#2938)
---
.github/workflows/flink_cdc.yml | 3 +-
docs/content.zh/docs/connectors/kafka.md | 224 ++++++++++
docs/content/docs/connectors/kafka.md | 222 +++++++++
.../flink-cdc-pipeline-connector-kafka/pom.xml | 99 +++++
.../kafka/json/ChangeLogJsonFormatFactory.java | 82 ++++
.../kafka/json/JsonSerializationType.java | 44 ++
.../cdc/connectors/kafka/json/TableSchemaInfo.java | 163 +++++++
.../json/canal/CanalJsonSerializationSchema.java | 212 +++++++++
.../debezium/DebeziumJsonSerializationSchema.java | 197 ++++++++
.../cdc/connectors/kafka/sink/KafkaDataSink.java | 100 +++++
.../kafka/sink/KafkaDataSinkFactory.java | 111 +++++
.../kafka/sink/KafkaDataSinkOptions.java | 66 +++
.../PipelineKafkaRecordSerializationSchema.java | 157 +++++++
.../org.apache.flink.cdc.common.factories.Factory | 16 +
.../kafka/json/MockInitializationContext.java | 38 ++
.../connectors/kafka/json/TableSchemaInfoTest.java | 164 +++++++
.../canal/CanalJsonSerializationSchemaTest.java | 133 ++++++
.../DebeziumJsonSerializationSchemaTest.java | 128 ++++++
.../kafka/sink/KafkaDataSinkFactoryTest.java | 47 ++
.../connectors/kafka/sink/KafkaDataSinkITCase.java | 495 +++++++++++++++++++++
.../flink/cdc/connectors/kafka/sink/KafkaUtil.java | 188 ++++++++
.../src/test/resources/log4j2-test.properties | 26 ++
.../flink-cdc-pipeline-connectors/pom.xml | 1 +
23 files changed, 2915 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/flink_cdc.yml b/.github/workflows/flink_cdc.yml
index 9c72e7d5d..ad842d7b4 100644
--- a/.github/workflows/flink_cdc.yml
+++ b/.github/workflows/flink_cdc.yml
@@ -45,7 +45,8 @@ env:
flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-values,\
flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-mysql,\
flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-doris,\
-
flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-starrocks"
+
flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-starrocks,\
+
flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka"
MODULES_MYSQL: "\
flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc,\
diff --git a/docs/content.zh/docs/connectors/kafka.md
b/docs/content.zh/docs/connectors/kafka.md
new file mode 100644
index 000000000..5402c406d
--- /dev/null
+++ b/docs/content.zh/docs/connectors/kafka.md
@@ -0,0 +1,224 @@
+---
+title: "Kafka"
+weight: 5
+type: docs
+aliases:
+- /connectors/kafka
+---
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# Kafka Pipeline 连接器
+
+Kafka Pipeline 连接器可以用作 Pipeline 的 *Data
Sink*,将数据写入[Kafka](https://kafka.apache.org)。 本文档介绍如何设置 Kafka Pipeline 连接器。
+
+## 连接器的功能
+* 自动建表
+* 表结构变更同步
+* 数据实时同步
+
+如何创建 Pipeline
+----------------
+
+从 MySQL 读取数据同步到 Kafka 的 Pipeline 可以定义如下:
+
+```yaml
+source:
+ type: mysql
+ name: MySQL Source
+ hostname: 127.0.0.1
+ port: 3306
+ username: admin
+ password: pass
+ tables: adb.\.*, bdb.user_table_[0-9]+, [app|web].order_\.*
+ server-id: 5401-5404
+
+sink:
+ type: kafka
+ name: Kafka Sink
+ properties.bootstrap.servers: PLAINTEXT://localhost:62510
+
+pipeline:
+ name: MySQL to Kafka Pipeline
+ parallelism: 2
+```
+
+Pipeline 连接器配置项
+----------------
+<div class="highlight">
+<table class="colwidths-auto docutils">
+ <thead>
+ <tr>
+ <th class="text-left" style="width: 25%">Option</th>
+ <th class="text-left" style="width: 8%">Required</th>
+ <th class="text-left" style="width: 7%">Default</th>
+ <th class="text-left" style="width: 10%">Type</th>
+ <th class="text-left" style="width: 50%">Description</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>type</td>
+ <td>required</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>指定要使用的连接器, 这里需要设置成 <code>'kafka'</code>。 </td>
+ </tr>
+ <tr>
+ <td>name</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>Sink 的名称。 </td>
+ </tr>
+ <tr>
+ <td>value.format</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>用于序列化 Kafka 消息的值部分数据的格式。可选的填写值包括 <a
href="https://debezium.io/documentation/reference/stable/integrations/serdes.html">debezium-json</a>
和 <a href="https://github.com/alibaba/canal/wiki">canal-json</a>, 默认值为
`debezium-json`,并且目前不支持用户自定义输出格式。 </td>
+ </tr>
+ <tr>
+ <td>properties.bootstrap.servers</td>
+ <td>required</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>用于建立与 Kafka 集群初始连接的主机/端口对列表。</td>
+ </tr>
+ <tr>
+ <td>topic</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>如果配置了这个参数,所有的消息都会发送到这一个主题。</td>
+ </tr>
+ <tr>
+ <td>sink.add-tableId-to-header-enabled</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>Boolean</td>
+ <td>如果配置了这个参数,所有的消息都会带上键为 `namespace`, 'schemaName', 'tableName',值为事件
TableId 里对应的 字符串的 header。</td>
+ </tr>
+ <tr>
+ <td>properties.*</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>将 Kafka 支持的参数传递给 pipeline,参考 <a
href="https://kafka.apache.org/28/documentation.html#consumerconfigs">Kafka
consume options</a>。 </td>
+ </tr>
+ <tr>
+ <td>sink.custom-header</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>Kafka 记录自定义的 Header。每个 Header 使用 ','分割, 键值使用 ':' 分割。举例来说,可以使用这种方式
'key1:value1,key2:value2'。 </td>
+ </tr>
+ </tbody>
+</table>
+</div>
+
+使用说明
+--------
+
+* 写入 Kafka 的 topic 默认会是上游表 `namespace.schemaName.tableName` 对应的字符串,可以通过
pipeline 的 route 功能进行修改。
+* 如果配置了 `topic` 参数,所有的消息都会发送到这一个主题。
+* 写入 Kafka 的 topic 如果不存在,则会默认创建。
+
+数据类型映射
+----------------
+<div class="wy-table-responsive">
+<table class="colwidths-auto docutils">
+ <thead>
+ <tr>
+ <th class="text-left">CDC type</th>
+ <th class="text-left">JSON type</th>
+ <th class="text-left" style="width:60%;">NOTE</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>TINYINT</td>
+ <td>TINYINT</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>SMALLINT</td>
+ <td>SMALLINT</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>INT</td>
+ <td>INT</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>BIGINT</td>
+ <td>BIGINT</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>FLOAT</td>
+ <td>FLOAT</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>DOUBLE</td>
+ <td>DOUBLE</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>DECIMAL(p, s)</td>
+ <td>DECIMAL(p, s)</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>BOOLEAN</td>
+ <td>BOOLEAN</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>DATE</td>
+ <td>DATE</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>TIMESTAMP</td>
+ <td>DATETIME</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>TIMESTAMP_LTZ</td>
+ <td>TIMESTAMP_LTZ</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>CHAR(n)</td>
+ <td>CHAR(n)</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>VARCHAR(n)</td>
+ <td>VARCHAR(n)</td>
+ <td></td>
+ </tr>
+ </tbody>
+</table>
+</div>
+
+{{< top >}}
\ No newline at end of file
diff --git a/docs/content/docs/connectors/kafka.md
b/docs/content/docs/connectors/kafka.md
new file mode 100644
index 000000000..83a735a2d
--- /dev/null
+++ b/docs/content/docs/connectors/kafka.md
@@ -0,0 +1,222 @@
+---
+title: "Kafka"
+weight: 5
+type: docs
+aliases:
+- /connectors/kafka
+---
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# Kafka Pipeline Connector
+
+The Kafka Pipeline connector can be used as the *Data Sink* of the pipeline,
and write data to [Kafka](https://kafka.apache.org). This document describes
how to set up the Kafka Pipeline connector.
+
+## What can the connector do?
+* Data synchronization
+
+How to create Pipeline
+----------------
+
+The pipeline for reading data from MySQL and sink to Kafka can be defined as
follows:
+
+```yaml
+source:
+ type: mysql
+ name: MySQL Source
+ hostname: 127.0.0.1
+ port: 3306
+ username: admin
+ password: pass
+ tables: adb.\.*, bdb.user_table_[0-9]+, [app|web].order_\.*
+ server-id: 5401-5404
+
+sink:
+ type: kafka
+ name: Kafka Sink
+ properties.bootstrap.servers: PLAINTEXT://localhost:62510
+
+pipeline:
+ name: MySQL to Kafka Pipeline
+ parallelism: 2
+```
+
+Pipeline Connector Options
+----------------
+<div class="highlight">
+<table class="colwidths-auto docutils">
+ <thead>
+ <tr>
+ <th class="text-left" style="width: 25%">Option</th>
+ <th class="text-left" style="width: 8%">Required</th>
+ <th class="text-left" style="width: 7%">Default</th>
+ <th class="text-left" style="width: 10%">Type</th>
+ <th class="text-left" style="width: 50%">Description</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>type</td>
+ <td>required</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>Specify what connector to use, here should be
<code>'kafka'</code>.</td>
+ </tr>
+ <tr>
+ <td>name</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>The name of the sink.</td>
+ </tr>
+ <tr>
+ <td>value.format</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>The format used to serialize the value part of Kafka messages.
Available options are <a
href="https://debezium.io/documentation/reference/stable/integrations/serdes.html">debezium-json</a>
and <a href="https://github.com/alibaba/canal/wiki">canal-json</a>, default
option is `debezium-json`, and do not support user-defined format now. </td>
+ </tr>
+ <tr>
+ <td>properties.bootstrap.servers</td>
+ <td>required</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>A list of host/port pairs to use for establishing the initial
connection to the Kafka cluster.</td>
+ </tr>
+ <tr>
+ <td>topic</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>If this parameter is configured, all events will be sent to this
topic.</td>
+ </tr>
+ <tr>
+ <td>sink.add-tableId-to-header-enabled</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>Boolean</td>
+ <td>If this parameter is true, a header with key of
'namespace','schemaName','tableName' will be added for each Kafka record.
Default value is false.</td>
+ </tr>
+ <tr>
+ <td>properties.*</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>Pass options of Kafka table to pipeline,See <a
href="https://kafka.apache.org/28/documentation.html#consumerconfigs">Kafka
consume options</a>. </td>
+ </tr>
+ <tr>
+ <td>sink.custom-header</td>
+ <td>optional</td>
+ <td style="word-wrap: break-word;">(none)</td>
+ <td>String</td>
+ <td>custom headers for each kafka record. Each header are separated by
',', separate key and value by ':'. For example, we can set headers like
'key1:value1,key2:value2'. </td>
+ </tr>
+ </tbody>
+</table>
+</div>
+
+Usage Notes
+--------
+
+* The written topic of Kafka will be `namespace.schemaName.tableName` string
of TableId,this can be changed using route function of pipeline.
+
+* If the written topic of Kafka is not existed, we will create one
automatically.
+
+Data Type Mapping
+----------------
+<div class="wy-table-responsive">
+<table class="colwidths-auto docutils">
+ <thead>
+ <tr>
+ <th class="text-left">CDC type</th>
+ <th class="text-left">JSON type</th>
+ <th class="text-left" style="width:60%;">NOTE</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td>TINYINT</td>
+ <td>TINYINT</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>SMALLINT</td>
+ <td>SMALLINT</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>INT</td>
+ <td>INT</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>BIGINT</td>
+ <td>BIGINT</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>FLOAT</td>
+ <td>FLOAT</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>DOUBLE</td>
+ <td>DOUBLE</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>DECIMAL(p, s)</td>
+ <td>DECIMAL(p, s)</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>BOOLEAN</td>
+ <td>BOOLEAN</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>DATE</td>
+ <td>DATE</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>TIMESTAMP</td>
+ <td>TIMESTAMP</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>TIMESTAMP_LTZ</td>
+ <td>TIMESTAMP_LTZ</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>CHAR(n)</td>
+ <td>CHAR(n)</td>
+ <td></td>
+ </tr>
+ <tr>
+ <td>VARCHAR(n)</td>
+ <td>VARCHAR(n)</td>
+ <td></td>
+ </tr>
+ </tbody>
+</table>
+</div>
+
+{{< top >}}
\ No newline at end of file
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/pom.xml
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/pom.xml
new file mode 100644
index 000000000..0b0c6f687
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/pom.xml
@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <parent>
+ <artifactId>flink-cdc-pipeline-connectors</artifactId>
+ <groupId>org.apache.flink</groupId>
+ <version>${revision}</version>
+ </parent>
+ <modelVersion>4.0.0</modelVersion>
+
+ <artifactId>flink-cdc-pipeline-connector-kafka</artifactId>
+
+ <properties>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.flink</groupId>
+ <artifactId>flink-connector-kafka</artifactId>
+ <version>3.0.2-${flink.major.version}</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.flink</groupId>
+ <artifactId>flink-json</artifactId>
+ <version>${flink.version}</version>
+ <scope>provided</scope>
+ </dependency>
+
+ <!-- Test dependencies -->
+ <dependency>
+ <groupId>org.apache.flink</groupId>
+ <artifactId>flink-cdc-composer</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>kafka</artifactId>
+ <version>${testcontainers.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.apache.flink</groupId>
+ <artifactId>flink-test-utils</artifactId>
+ <version>${flink.version}</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-shade-plugin</artifactId>
+ <version>3.1.1</version>
+ <executions>
+ <execution>
+ <id>shade-flink</id>
+ <phase>package</phase>
+ <goals>
+ <goal>shade</goal>
+ </goals>
+ <configuration>
+ <shadeTestJar>false</shadeTestJar>
+ <relocations>
+ <relocation>
+ <pattern>org.apache.kafka</pattern>
+
<shadedPattern>org.apache.flink.cdc.connectors.kafka.shaded.org.apache.kafka</shadedPattern>
+ </relocation>
+ </relocations>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
\ No newline at end of file
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/ChangeLogJsonFormatFactory.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/ChangeLogJsonFormatFactory.java
new file mode 100644
index 000000000..81c401804
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/ChangeLogJsonFormatFactory.java
@@ -0,0 +1,82 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.json;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.cdc.common.event.Event;
+import
org.apache.flink.cdc.connectors.kafka.json.canal.CanalJsonSerializationSchema;
+import
org.apache.flink.cdc.connectors.kafka.json.debezium.DebeziumJsonSerializationSchema;
+import org.apache.flink.configuration.ReadableConfig;
+import org.apache.flink.formats.common.TimestampFormat;
+import org.apache.flink.formats.json.JsonFormatOptions;
+import org.apache.flink.formats.json.JsonFormatOptionsUtil;
+
+import static
org.apache.flink.formats.json.JsonFormatOptions.ENCODE_DECIMAL_AS_PLAIN_NUMBER;
+import static
org.apache.flink.formats.json.debezium.DebeziumJsonFormatOptions.JSON_MAP_NULL_KEY_LITERAL;
+
+/**
+ * Format factory for providing configured instances of {@link
SerializationSchema} to convert
+ * {@link Event} to json.
+ */
+@Internal
+public class ChangeLogJsonFormatFactory {
+
+ /**
+ * Creates a configured instance of {@link SerializationSchema} to convert
{@link Event} to
+ * json.
+ *
+ * @param formatOptions The format options.
+ * @param type The type of json serialization.
+ * @return The configured instance of {@link SerializationSchema}.
+ */
+ public static SerializationSchema<Event> createSerializationSchema(
+ ReadableConfig formatOptions, JsonSerializationType type) {
+ TimestampFormat timestampFormat =
JsonFormatOptionsUtil.getTimestampFormat(formatOptions);
+ JsonFormatOptions.MapNullKeyMode mapNullKeyMode =
+ JsonFormatOptionsUtil.getMapNullKeyMode(formatOptions);
+ String mapNullKeyLiteral =
formatOptions.get(JSON_MAP_NULL_KEY_LITERAL);
+
+ final boolean encodeDecimalAsPlainNumber =
+ formatOptions.get(ENCODE_DECIMAL_AS_PLAIN_NUMBER);
+
+ switch (type) {
+ case DEBEZIUM_JSON:
+ {
+ return new DebeziumJsonSerializationSchema(
+ timestampFormat,
+ mapNullKeyMode,
+ mapNullKeyLiteral,
+ encodeDecimalAsPlainNumber);
+ }
+ case CANAL_JSON:
+ {
+ return new CanalJsonSerializationSchema(
+ timestampFormat,
+ mapNullKeyMode,
+ mapNullKeyLiteral,
+ encodeDecimalAsPlainNumber);
+ }
+ default:
+ {
+ throw new IllegalArgumentException(
+ "unSupport JsonSerializationType of " + type);
+ }
+ }
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/JsonSerializationType.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/JsonSerializationType.java
new file mode 100644
index 000000000..f7b5e0ffc
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/JsonSerializationType.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.json;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.cdc.common.event.Event;
+import
org.apache.flink.cdc.connectors.kafka.json.canal.CanalJsonSerializationSchema;
+import
org.apache.flink.cdc.connectors.kafka.json.debezium.DebeziumJsonSerializationSchema;
+
+/** type of {@link SerializationSchema} to serialize {@link Event} for kafka.
*/
+public enum JsonSerializationType {
+
+ /** Use {@link DebeziumJsonSerializationSchema} to serialize. */
+ DEBEZIUM_JSON("debezium-json"),
+
+ /** Use {@link CanalJsonSerializationSchema} to serialize. */
+ CANAL_JSON("canal-json");
+
+ private final String value;
+
+ JsonSerializationType(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String toString() {
+ return value;
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/TableSchemaInfo.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/TableSchemaInfo.java
new file mode 100644
index 000000000..a8480d77d
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/TableSchemaInfo.java
@@ -0,0 +1,163 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.json;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.cdc.common.data.RecordData;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.table.data.DecimalData;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.data.binary.BinaryStringData;
+
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.apache.flink.cdc.common.types.DataTypeChecks.getPrecision;
+import static org.apache.flink.cdc.common.types.DataTypeChecks.getScale;
+
+/** maintain the {@link SerializationSchema} of a specific {@link TableId}. */
+public class TableSchemaInfo {
+
+ private final Schema schema;
+
+ private final List<RecordData.FieldGetter> fieldGetters;
+
+ private final SerializationSchema<RowData> serializationSchema;
+
+ public TableSchemaInfo(
+ Schema schema, SerializationSchema<RowData> serializationSchema,
ZoneId zoneId) {
+ this.schema = schema;
+ this.serializationSchema = serializationSchema;
+ this.fieldGetters = createFieldGetters(schema, zoneId);
+ }
+
+ /** convert to {@link RowData}, which will be pass to serializationSchema.
*/
+ public RowData getRowDataFromRecordData(RecordData recordData) {
+ GenericRowData genericRowData = new
GenericRowData(recordData.getArity());
+ for (int i = 0; i < recordData.getArity(); i++) {
+ genericRowData.setField(i,
fieldGetters.get(i).getFieldOrNull(recordData));
+ }
+ return genericRowData;
+ }
+
+ private static List<RecordData.FieldGetter> createFieldGetters(Schema
schema, ZoneId zoneId) {
+ List<RecordData.FieldGetter> fieldGetters = new
ArrayList<>(schema.getColumns().size());
+ for (int i = 0; i < schema.getColumns().size(); i++) {
+
fieldGetters.add(createFieldGetter(schema.getColumns().get(i).getType(), i,
zoneId));
+ }
+ return fieldGetters;
+ }
+
+ private static RecordData.FieldGetter createFieldGetter(
+ DataType fieldType, int fieldPos, ZoneId zoneId) {
+ final RecordData.FieldGetter fieldGetter;
+ // ordered by type root definition
+ switch (fieldType.getTypeRoot()) {
+ case CHAR:
+ case VARCHAR:
+ fieldGetter =
+ record ->
+
BinaryStringData.fromString(record.getString(fieldPos).toString());
+ break;
+ case BOOLEAN:
+ fieldGetter = record -> record.getBoolean(fieldPos);
+ break;
+ case BINARY:
+ case VARBINARY:
+ fieldGetter = record -> record.getBinary(fieldPos);
+ break;
+ case DECIMAL:
+ final int decimalPrecision = getPrecision(fieldType);
+ final int decimalScale = getScale(fieldType);
+ fieldGetter =
+ record ->
+ DecimalData.fromBigDecimal(
+ record.getDecimal(fieldPos,
decimalPrecision, decimalScale)
+ .toBigDecimal(),
+ decimalPrecision,
+ decimalScale);
+ break;
+ case TINYINT:
+ fieldGetter = record -> record.getByte(fieldPos);
+ break;
+ case SMALLINT:
+ fieldGetter = record -> record.getShort(fieldPos);
+ break;
+ case INTEGER:
+ case DATE:
+ case TIME_WITHOUT_TIME_ZONE:
+ fieldGetter = record -> record.getInt(fieldPos);
+ break;
+ case BIGINT:
+ fieldGetter = record -> record.getLong(fieldPos);
+ break;
+ case FLOAT:
+ fieldGetter = record -> record.getFloat(fieldPos);
+ break;
+ case DOUBLE:
+ fieldGetter = record -> record.getDouble(fieldPos);
+ break;
+ case TIMESTAMP_WITHOUT_TIME_ZONE:
+ fieldGetter =
+ record ->
+ TimestampData.fromTimestamp(
+ record.getTimestamp(fieldPos,
getPrecision(fieldType))
+ .toTimestamp());
+ break;
+ case TIMESTAMP_WITH_TIME_ZONE:
+ case TIMESTAMP_WITH_LOCAL_TIME_ZONE:
+ fieldGetter =
+ record ->
+ TimestampData.fromLocalDateTime(
+ ZonedDateTime.ofInstant(
+
record.getLocalZonedTimestampData(
+
fieldPos,
+
getPrecision(fieldType))
+ .toInstant(),
+ zoneId)
+ .toLocalDateTime());
+ break;
+ default:
+ throw new IllegalArgumentException(
+ "don't support type of " + fieldType.getTypeRoot());
+ }
+ if (!fieldType.isNullable()) {
+ return fieldGetter;
+ }
+ return row -> {
+ if (row.isNullAt(fieldPos)) {
+ return null;
+ }
+ return fieldGetter.getFieldOrNull(row);
+ };
+ }
+
+ public Schema getSchema() {
+ return schema;
+ }
+
+ public SerializationSchema<RowData> getSerializationSchema() {
+ return serializationSchema;
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/canal/CanalJsonSerializationSchema.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/canal/CanalJsonSerializationSchema.java
new file mode 100644
index 000000000..eecd2b801
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/canal/CanalJsonSerializationSchema.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.json.canal;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.SchemaChangeEvent;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.utils.DataTypeUtils;
+import org.apache.flink.cdc.common.utils.SchemaUtils;
+import org.apache.flink.cdc.connectors.kafka.json.TableSchemaInfo;
+import org.apache.flink.formats.common.TimestampFormat;
+import org.apache.flink.formats.json.JsonFormatOptions;
+import org.apache.flink.formats.json.JsonRowDataSerializationSchema;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.data.GenericArrayData;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.RowType;
+
+import java.time.ZoneId;
+import java.util.HashMap;
+import java.util.Map;
+
+import static java.lang.String.format;
+import static
org.apache.flink.table.types.utils.TypeConversions.fromLogicalToDataType;
+
+/**
+ * Serialization schema that serializes an object of FlinkCDC pipeline
internal data structure
+ * {@link Event} into a Canal JSON bytes.
+ *
+ * @see <a href="https://github.com/alibaba/canal">Alibaba Canal</a>
+ */
+public class CanalJsonSerializationSchema implements
SerializationSchema<Event> {
+
+ private static final long serialVersionUID = 1L;
+
+ private static final StringData OP_INSERT =
StringData.fromString("INSERT");
+ private static final StringData OP_DELETE =
StringData.fromString("DELETE");
+ private static final StringData OP_UPDATE =
StringData.fromString("UPDATE");
+
+ private transient GenericRowData reuseGenericRowData;
+
+ /** The serializer to serialize Canal JSON data. */
+ private final Map<TableId, TableSchemaInfo> jsonSerializers;
+
+ private final TimestampFormat timestampFormat;
+
+ private final JsonFormatOptions.MapNullKeyMode mapNullKeyMode;
+
+ private final String mapNullKeyLiteral;
+
+ private final boolean encodeDecimalAsPlainNumber;
+
+ private final ZoneId zoneId;
+
+ private InitializationContext context;
+
+ public CanalJsonSerializationSchema(
+ TimestampFormat timestampFormat,
+ JsonFormatOptions.MapNullKeyMode mapNullKeyMode,
+ String mapNullKeyLiteral,
+ boolean encodeDecimalAsPlainNumber) {
+ this.timestampFormat = timestampFormat;
+ this.mapNullKeyMode = mapNullKeyMode;
+ this.mapNullKeyLiteral = mapNullKeyLiteral;
+ this.encodeDecimalAsPlainNumber = encodeDecimalAsPlainNumber;
+ this.zoneId = ZoneId.systemDefault();
+ jsonSerializers = new HashMap<>();
+ }
+
+ @Override
+ public void open(InitializationContext context) {
+ this.context = context;
+ reuseGenericRowData = new GenericRowData(3);
+ }
+
+ @Override
+ public byte[] serialize(Event event) {
+ if (event instanceof SchemaChangeEvent) {
+ Schema schema;
+ SchemaChangeEvent schemaChangeEvent = (SchemaChangeEvent) event;
+ if (event instanceof CreateTableEvent) {
+ CreateTableEvent createTableEvent = (CreateTableEvent) event;
+ schema = createTableEvent.getSchema();
+ } else {
+ schema =
+ SchemaUtils.applySchemaChangeEvent(
+
jsonSerializers.get(schemaChangeEvent.tableId()).getSchema(),
+ schemaChangeEvent);
+ }
+ LogicalType rowType =
+
DataTypeUtils.toFlinkDataType(schema.toRowDataType()).getLogicalType();
+ JsonRowDataSerializationSchema jsonSerializer =
+ new JsonRowDataSerializationSchema(
+ createJsonRowType(fromLogicalToDataType(rowType)),
+ timestampFormat,
+ mapNullKeyMode,
+ mapNullKeyLiteral,
+ encodeDecimalAsPlainNumber);
+ try {
+ jsonSerializer.open(context);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ jsonSerializers.put(
+ schemaChangeEvent.tableId(),
+ new TableSchemaInfo(schema, jsonSerializer, zoneId));
+ return null;
+ }
+
+ DataChangeEvent dataChangeEvent = (DataChangeEvent) event;
+ try {
+ switch (dataChangeEvent.op()) {
+ case INSERT:
+ reuseGenericRowData.setField(0, null);
+ reuseGenericRowData.setField(
+ 1,
+ new GenericArrayData(
+ new RowData[] {
+ jsonSerializers
+ .get(dataChangeEvent.tableId())
+
.getRowDataFromRecordData((dataChangeEvent.after()))
+ }));
+ reuseGenericRowData.setField(2, OP_INSERT);
+ return jsonSerializers
+ .get(dataChangeEvent.tableId())
+ .getSerializationSchema()
+ .serialize(reuseGenericRowData);
+ case DELETE:
+ reuseGenericRowData.setField(
+ 0,
+ new GenericArrayData(
+ new RowData[] {
+ jsonSerializers
+ .get(dataChangeEvent.tableId())
+ .getRowDataFromRecordData(
+
(dataChangeEvent.before()))
+ }));
+ reuseGenericRowData.setField(1, null);
+ reuseGenericRowData.setField(2, OP_DELETE);
+ return jsonSerializers
+ .get(dataChangeEvent.tableId())
+ .getSerializationSchema()
+ .serialize(reuseGenericRowData);
+ case UPDATE:
+ case REPLACE:
+ reuseGenericRowData.setField(
+ 0,
+ new GenericArrayData(
+ new RowData[] {
+ jsonSerializers
+ .get(dataChangeEvent.tableId())
+ .getRowDataFromRecordData(
+
(dataChangeEvent.before()))
+ }));
+ reuseGenericRowData.setField(
+ 1,
+ new GenericArrayData(
+ new RowData[] {
+ jsonSerializers
+ .get(dataChangeEvent.tableId())
+
.getRowDataFromRecordData((dataChangeEvent.after()))
+ }));
+ reuseGenericRowData.setField(2, OP_UPDATE);
+ return jsonSerializers
+ .get(dataChangeEvent.tableId())
+ .getSerializationSchema()
+ .serialize(reuseGenericRowData);
+ default:
+ throw new UnsupportedOperationException(
+ format(
+ "Unsupported operation '%s' for
OperationType.",
+ dataChangeEvent.op()));
+ }
+ } catch (Throwable t) {
+ throw new RuntimeException(format("Could not serialize event
'%s'.", event), t);
+ }
+ }
+
+ private static RowType createJsonRowType(DataType databaseSchema) {
+ // Canal JSON contains other information, e.g. "database", "ts"
+ // but we don't need them
+ return (RowType)
+ DataTypes.ROW(
+ DataTypes.FIELD("old",
DataTypes.ARRAY(databaseSchema)),
+ DataTypes.FIELD("data",
DataTypes.ARRAY(databaseSchema)),
+ DataTypes.FIELD("type", DataTypes.STRING()))
+ .getLogicalType();
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/debezium/DebeziumJsonSerializationSchema.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/debezium/DebeziumJsonSerializationSchema.java
new file mode 100644
index 000000000..3fa1d4ce1
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/json/debezium/DebeziumJsonSerializationSchema.java
@@ -0,0 +1,197 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.json.debezium;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.SchemaChangeEvent;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.utils.DataTypeUtils;
+import org.apache.flink.cdc.common.utils.SchemaUtils;
+import org.apache.flink.cdc.connectors.kafka.json.TableSchemaInfo;
+import org.apache.flink.formats.common.TimestampFormat;
+import org.apache.flink.formats.json.JsonFormatOptions;
+import org.apache.flink.formats.json.JsonRowDataSerializationSchema;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.types.DataType;
+import org.apache.flink.table.types.logical.LogicalType;
+import org.apache.flink.table.types.logical.RowType;
+
+import java.time.ZoneId;
+import java.util.HashMap;
+import java.util.Map;
+
+import static java.lang.String.format;
+import static
org.apache.flink.table.types.utils.TypeConversions.fromLogicalToDataType;
+
+/**
+ * Serialization schema from FlinkCDC pipeline internal data structure {@link
Event} to Debezium
+ * JSON.
+ *
+ * @see <a href="https://debezium.io/">Debezium</a>
+ */
+public class DebeziumJsonSerializationSchema implements
SerializationSchema<Event> {
+ private static final long serialVersionUID = 1L;
+
+ private static final StringData OP_INSERT = StringData.fromString("c"); //
insert
+ private static final StringData OP_DELETE = StringData.fromString("d"); //
delete
+ private static final StringData OP_UPDATE = StringData.fromString("u"); //
update
+
+ /**
+ * A map of {@link TableId} and its {@link SerializationSchema} to
serialize Debezium JSON data.
+ */
+ private final Map<TableId, TableSchemaInfo> jsonSerializers;
+
+ private transient GenericRowData reuseGenericRowData;
+
+ private final TimestampFormat timestampFormat;
+
+ private final JsonFormatOptions.MapNullKeyMode mapNullKeyMode;
+
+ private final String mapNullKeyLiteral;
+
+ private final boolean encodeDecimalAsPlainNumber;
+
+ private final ZoneId zoneId;
+
+ private InitializationContext context;
+
+ public DebeziumJsonSerializationSchema(
+ TimestampFormat timestampFormat,
+ JsonFormatOptions.MapNullKeyMode mapNullKeyMode,
+ String mapNullKeyLiteral,
+ boolean encodeDecimalAsPlainNumber) {
+ this.timestampFormat = timestampFormat;
+ this.mapNullKeyMode = mapNullKeyMode;
+ this.mapNullKeyLiteral = mapNullKeyLiteral;
+ this.encodeDecimalAsPlainNumber = encodeDecimalAsPlainNumber;
+ this.zoneId = ZoneId.systemDefault();
+ jsonSerializers = new HashMap<>();
+ }
+
+ @Override
+ public void open(InitializationContext context) {
+ reuseGenericRowData = new GenericRowData(3);
+ this.context = context;
+ }
+
+ @Override
+ public byte[] serialize(Event event) {
+ if (event instanceof SchemaChangeEvent) {
+ Schema schema;
+ SchemaChangeEvent schemaChangeEvent = (SchemaChangeEvent) event;
+ if (event instanceof CreateTableEvent) {
+ CreateTableEvent createTableEvent = (CreateTableEvent) event;
+ schema = createTableEvent.getSchema();
+ } else {
+ schema =
+ SchemaUtils.applySchemaChangeEvent(
+
jsonSerializers.get(schemaChangeEvent.tableId()).getSchema(),
+ schemaChangeEvent);
+ }
+ LogicalType rowType =
+
DataTypeUtils.toFlinkDataType(schema.toRowDataType()).getLogicalType();
+ JsonRowDataSerializationSchema jsonSerializer =
+ new JsonRowDataSerializationSchema(
+ createJsonRowType(fromLogicalToDataType(rowType)),
+ timestampFormat,
+ mapNullKeyMode,
+ mapNullKeyLiteral,
+ encodeDecimalAsPlainNumber);
+ try {
+ jsonSerializer.open(context);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ jsonSerializers.put(
+ schemaChangeEvent.tableId(),
+ new TableSchemaInfo(schema, jsonSerializer, zoneId));
+ return null;
+ }
+
+ DataChangeEvent dataChangeEvent = (DataChangeEvent) event;
+ try {
+ switch (dataChangeEvent.op()) {
+ case INSERT:
+ reuseGenericRowData.setField(0, null);
+ reuseGenericRowData.setField(
+ 1,
+ jsonSerializers
+ .get(dataChangeEvent.tableId())
+
.getRowDataFromRecordData(dataChangeEvent.after()));
+ reuseGenericRowData.setField(2, OP_INSERT);
+ return jsonSerializers
+ .get(dataChangeEvent.tableId())
+ .getSerializationSchema()
+ .serialize(reuseGenericRowData);
+ case DELETE:
+ reuseGenericRowData.setField(
+ 0,
+ jsonSerializers
+ .get(dataChangeEvent.tableId())
+
.getRowDataFromRecordData(dataChangeEvent.before()));
+ reuseGenericRowData.setField(1, null);
+ reuseGenericRowData.setField(2, OP_DELETE);
+ return jsonSerializers
+ .get(dataChangeEvent.tableId())
+ .getSerializationSchema()
+ .serialize(reuseGenericRowData);
+ case UPDATE:
+ case REPLACE:
+ reuseGenericRowData.setField(
+ 0,
+ jsonSerializers
+ .get(dataChangeEvent.tableId())
+
.getRowDataFromRecordData(dataChangeEvent.before()));
+ reuseGenericRowData.setField(
+ 1,
+ jsonSerializers
+ .get(dataChangeEvent.tableId())
+
.getRowDataFromRecordData(dataChangeEvent.after()));
+ reuseGenericRowData.setField(2, OP_UPDATE);
+ return jsonSerializers
+ .get(dataChangeEvent.tableId())
+ .getSerializationSchema()
+ .serialize(reuseGenericRowData);
+ default:
+ throw new UnsupportedOperationException(
+ format(
+ "Unsupported operation '%s' for
OperationType.",
+ dataChangeEvent.op()));
+ }
+ } catch (Throwable t) {
+ throw new RuntimeException(format("Could not serialize event
'%s'.", event), t);
+ }
+ }
+
+ private static RowType createJsonRowType(DataType databaseSchema) {
+ // Debezium JSON contains some other information, e.g. "source",
"ts_ms"
+ // but we don't need them.
+ return (RowType)
+ DataTypes.ROW(
+ DataTypes.FIELD("before", databaseSchema),
+ DataTypes.FIELD("after", databaseSchema),
+ DataTypes.FIELD("op", DataTypes.STRING()))
+ .getLogicalType();
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSink.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSink.java
new file mode 100644
index 000000000..947bde1a8
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSink.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.sink;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.sink.DataSink;
+import org.apache.flink.cdc.common.sink.EventSinkProvider;
+import org.apache.flink.cdc.common.sink.FlinkSinkProvider;
+import org.apache.flink.cdc.common.sink.MetadataApplier;
+import org.apache.flink.connector.base.DeliveryGuarantee;
+import org.apache.flink.connector.kafka.sink.KafkaSink;
+import org.apache.flink.connector.kafka.sink.KafkaSinkBuilder;
+import
org.apache.flink.streaming.connectors.kafka.partitioner.FlinkKafkaPartitioner;
+
+import org.apache.kafka.clients.producer.ProducerConfig;
+
+import java.time.ZoneId;
+import java.util.Properties;
+
+/** A {@link DataSink} for "Kafka" connector. */
+public class KafkaDataSink implements DataSink {
+
+ final Properties kafkaProperties;
+
+ final DeliveryGuarantee deliveryGuarantee;
+
+ final FlinkKafkaPartitioner<Event> partitioner;
+
+ final ZoneId zoneId;
+
+ final SerializationSchema<Event> valueSerialization;
+
+ final String topic;
+
+ final boolean addTableToHeaderEnabled;
+
+ final String customHeaders;
+
+ public KafkaDataSink(
+ DeliveryGuarantee deliveryGuarantee,
+ Properties kafkaProperties,
+ FlinkKafkaPartitioner<Event> partitioner,
+ ZoneId zoneId,
+ SerializationSchema<Event> valueSerialization,
+ String topic,
+ boolean addTableToHeaderEnabled,
+ String customHeaders) {
+ this.deliveryGuarantee = deliveryGuarantee;
+ this.kafkaProperties = kafkaProperties;
+ this.partitioner = partitioner;
+ this.zoneId = zoneId;
+ this.valueSerialization = valueSerialization;
+ this.topic = topic;
+ this.addTableToHeaderEnabled = addTableToHeaderEnabled;
+ this.customHeaders = customHeaders;
+ }
+
+ @Override
+ public EventSinkProvider getEventSinkProvider() {
+ final KafkaSinkBuilder<Event> sinkBuilder = KafkaSink.builder();
+ return FlinkSinkProvider.of(
+ sinkBuilder
+ .setDeliveryGuarantee(deliveryGuarantee)
+ .setBootstrapServers(
+ kafkaProperties
+
.get(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG)
+ .toString())
+ .setKafkaProducerConfig(kafkaProperties)
+ .setRecordSerializer(
+ new PipelineKafkaRecordSerializationSchema(
+ partitioner,
+ valueSerialization,
+ topic,
+ addTableToHeaderEnabled,
+ customHeaders))
+ .build());
+ }
+
+ @Override
+ public MetadataApplier getMetadataApplier() {
+ // simply do nothing here because Kafka do not maintain the schemas.
+ return schemaChangeEvent -> {};
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkFactory.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkFactory.java
new file mode 100644
index 000000000..9b117243f
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkFactory.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.sink;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.cdc.common.configuration.ConfigOption;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.factories.DataSinkFactory;
+import org.apache.flink.cdc.common.pipeline.PipelineOptions;
+import org.apache.flink.cdc.common.sink.DataSink;
+import org.apache.flink.cdc.connectors.kafka.json.ChangeLogJsonFormatFactory;
+import org.apache.flink.cdc.connectors.kafka.json.JsonSerializationType;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.connector.base.DeliveryGuarantee;
+import
org.apache.flink.streaming.connectors.kafka.partitioner.FlinkFixedPartitioner;
+
+import java.time.ZoneId;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.Set;
+
+import static
org.apache.flink.cdc.connectors.kafka.sink.KafkaDataSinkOptions.PROPERTIES_PREFIX;
+
+/** A dummy {@link DataSinkFactory} to create {@link KafkaDataSink}. */
+public class KafkaDataSinkFactory implements DataSinkFactory {
+
+ public static final String IDENTIFIER = "kafka";
+
+ @Override
+ public DataSink createDataSink(Context context) {
+ Configuration configuration =
+
Configuration.fromMap(context.getFactoryConfiguration().toMap());
+ DeliveryGuarantee deliveryGuarantee =
+
context.getFactoryConfiguration().get(KafkaDataSinkOptions.DELIVERY_GUARANTEE);
+ ZoneId zoneId = ZoneId.systemDefault();
+ if (!Objects.equals(
+
context.getPipelineConfiguration().get(PipelineOptions.PIPELINE_LOCAL_TIME_ZONE),
+ PipelineOptions.PIPELINE_LOCAL_TIME_ZONE.defaultValue())) {
+ zoneId =
+ ZoneId.of(
+ context.getPipelineConfiguration()
+
.get(PipelineOptions.PIPELINE_LOCAL_TIME_ZONE));
+ }
+ JsonSerializationType jsonSerializationType =
+
context.getFactoryConfiguration().get(KafkaDataSinkOptions.VALUE_FORMAT);
+ SerializationSchema<Event> valueSerialization =
+ ChangeLogJsonFormatFactory.createSerializationSchema(
+ configuration, jsonSerializationType);
+ final Properties kafkaProperties = new Properties();
+ Map<String, String> allOptions =
context.getFactoryConfiguration().toMap();
+ allOptions.keySet().stream()
+ .filter(key -> key.startsWith(PROPERTIES_PREFIX))
+ .forEach(
+ key -> {
+ final String value = allOptions.get(key);
+ final String subKey =
key.substring((PROPERTIES_PREFIX).length());
+ kafkaProperties.put(subKey, value);
+ });
+ String topic =
context.getFactoryConfiguration().get(KafkaDataSinkOptions.TOPIC);
+ boolean addTableToHeaderEnabled =
+ context.getFactoryConfiguration()
+
.get(KafkaDataSinkOptions.SINK_ADD_TABLEID_TO_HEADER_ENABLED);
+ String customHeaders =
+
context.getFactoryConfiguration().get(KafkaDataSinkOptions.SINK_CUSTOM_HEADER);
+ return new KafkaDataSink(
+ deliveryGuarantee,
+ kafkaProperties,
+ new FlinkFixedPartitioner<>(),
+ zoneId,
+ valueSerialization,
+ topic,
+ addTableToHeaderEnabled,
+ customHeaders);
+ }
+
+ @Override
+ public String identifier() {
+ return IDENTIFIER;
+ }
+
+ @Override
+ public Set<ConfigOption<?>> requiredOptions() {
+ return null;
+ }
+
+ @Override
+ public Set<ConfigOption<?>> optionalOptions() {
+ Set<ConfigOption<?>> options = new HashSet<>();
+ options.add(KafkaDataSinkOptions.DELIVERY_GUARANTEE);
+ options.add(KafkaDataSinkOptions.TOPIC);
+ options.add(KafkaDataSinkOptions.SINK_ADD_TABLEID_TO_HEADER_ENABLED);
+ return options;
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkOptions.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkOptions.java
new file mode 100644
index 000000000..e55e149a9
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkOptions.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.sink;
+
+import org.apache.flink.cdc.common.configuration.ConfigOption;
+import org.apache.flink.cdc.connectors.kafka.json.JsonSerializationType;
+import org.apache.flink.connector.base.DeliveryGuarantee;
+
+import static org.apache.flink.cdc.common.configuration.ConfigOptions.key;
+
+/** Options for {@link KafkaDataSinkOptions}. */
+public class KafkaDataSinkOptions {
+
+ // Prefix for Kafka specific properties.
+ public static final String PROPERTIES_PREFIX = "properties.";
+
+ public static final ConfigOption<DeliveryGuarantee> DELIVERY_GUARANTEE =
+ key("sink.delivery-guarantee")
+ .enumType(DeliveryGuarantee.class)
+ .defaultValue(DeliveryGuarantee.AT_LEAST_ONCE)
+ .withDescription("Optional delivery guarantee when
committing.");
+
+ public static final ConfigOption<JsonSerializationType> VALUE_FORMAT =
+ key("value.format")
+ .enumType(JsonSerializationType.class)
+ .defaultValue(JsonSerializationType.DEBEZIUM_JSON)
+ .withDescription(
+ "Defines the format identifier for encoding value
data, "
+ + "available options are `debezium-json`
and `canal-json`, default option is `debezium-json`.");
+
+ public static final ConfigOption<String> TOPIC =
+ key("topic")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "Optional. If this parameter is configured, all
events will be sent to this topic.");
+
+ public static final ConfigOption<Boolean>
SINK_ADD_TABLEID_TO_HEADER_ENABLED =
+ key("sink.add-tableId-to-header-enabled")
+ .booleanType()
+ .defaultValue(false)
+ .withDescription(
+ "Optional. If this parameter is configured, a
header with key of 'namespace','schemaName','tableName' will be added for each
Kafka record.");
+
+ public static final ConfigOption<String> SINK_CUSTOM_HEADER =
+ key("sink.custom-header")
+ .stringType()
+ .defaultValue("")
+ .withDescription(
+ "custom headers for each kafka record. Each header
are separated by ',', separate key and value by ':'. For example, we can set
headers like 'key1:value1,key2:value2'.");
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/PipelineKafkaRecordSerializationSchema.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/PipelineKafkaRecordSerializationSchema.java
new file mode 100644
index 000000000..bb27753d9
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/java/org/apache/flink/cdc/connectors/kafka/sink/PipelineKafkaRecordSerializationSchema.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.sink;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.cdc.common.event.ChangeEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.SchemaChangeEvent;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.connector.kafka.sink.KafkaRecordSerializationSchema;
+import
org.apache.flink.streaming.connectors.kafka.partitioner.FlinkKafkaPartitioner;
+
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.header.internals.RecordHeader;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+
+import javax.annotation.Nullable;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/**
+ * A {@link KafkaRecordSerializationSchema} to serialize {@link Event}.
+ *
+ * <p>The topic to be sent is the string value of {@link TableId}.
+ *
+ * <p>the key of {@link ProducerRecord} is null as we don't need to upsert
Kafka.
+ */
+public class PipelineKafkaRecordSerializationSchema
+ implements KafkaRecordSerializationSchema<Event> {
+ private final FlinkKafkaPartitioner<Event> partitioner;
+ private final SerializationSchema<Event> valueSerialization;
+
+ private final String unifiedTopic;
+
+ private final boolean addTableToHeaderEnabled;
+
+ // key value pairs to be put into Kafka Record Header.
+ public final Map<String, String> customHeaders;
+
+ public static final String NAMESPACE_HEADER_KEY = "namespace";
+
+ public static final String SCHEMA_NAME_HEADER_KEY = "schemaName";
+
+ public static final String TABLE_NAME_HEADER_KEY = "tableName";
+
+ PipelineKafkaRecordSerializationSchema(
+ @Nullable FlinkKafkaPartitioner<Event> partitioner,
+ SerializationSchema<Event> valueSerialization,
+ String unifiedTopic,
+ boolean addTableToHeaderEnabled,
+ String customHeaderString) {
+ this.partitioner = partitioner;
+ this.valueSerialization = checkNotNull(valueSerialization);
+ this.unifiedTopic = unifiedTopic;
+ this.addTableToHeaderEnabled = addTableToHeaderEnabled;
+ customHeaders = new HashMap<>();
+ if (!customHeaderString.isEmpty()) {
+ for (String tables : customHeaderString.split(";")) {
+ String[] splits = tables.split(":");
+ if (splits.length == 2) {
+ String headerKey = splits[0].trim();
+ String headerValue = splits[1].trim();
+ customHeaders.put(headerKey, headerValue);
+ } else {
+ throw new IllegalArgumentException(
+ KafkaDataSinkOptions.SINK_CUSTOM_HEADER
+ + " is malformed, please refer to the
documents");
+ }
+ }
+ }
+ }
+
+ @Override
+ public ProducerRecord<byte[], byte[]> serialize(
+ Event event, KafkaSinkContext context, Long timestamp) {
+ ChangeEvent changeEvent = (ChangeEvent) event;
+ final byte[] valueSerialized = valueSerialization.serialize(event);
+ if (event instanceof SchemaChangeEvent) {
+ // skip sending SchemaChangeEvent.
+ return null;
+ }
+ String topic = unifiedTopic == null ? changeEvent.tableId().toString()
: unifiedTopic;
+ RecordHeaders recordHeaders = new RecordHeaders();
+ if (addTableToHeaderEnabled) {
+ String namespace =
+ changeEvent.tableId().getNamespace() == null
+ ? ""
+ : changeEvent.tableId().getNamespace();
+ recordHeaders.add(new RecordHeader(NAMESPACE_HEADER_KEY,
namespace.getBytes(UTF_8)));
+ String schemaName =
+ changeEvent.tableId().getSchemaName() == null
+ ? ""
+ : changeEvent.tableId().getSchemaName();
+ recordHeaders.add(new RecordHeader(SCHEMA_NAME_HEADER_KEY,
schemaName.getBytes(UTF_8)));
+ String tableName = changeEvent.tableId().getTableName();
+ recordHeaders.add(new RecordHeader(TABLE_NAME_HEADER_KEY,
tableName.getBytes(UTF_8)));
+ }
+ if (!this.customHeaders.isEmpty()) {
+ for (Map.Entry<String, String> entry : customHeaders.entrySet()) {
+ recordHeaders.add(
+ new RecordHeader(entry.getKey(),
entry.getValue().getBytes(UTF_8)));
+ }
+ }
+ return new ProducerRecord<>(
+ topic,
+ extractPartition(
+ changeEvent, valueSerialized,
context.getPartitionsForTopic(topic)),
+ null,
+ null,
+ valueSerialized,
+ recordHeaders);
+ }
+
+ @Override
+ public void open(
+ SerializationSchema.InitializationContext context,
KafkaSinkContext sinkContext)
+ throws Exception {
+ if (partitioner != null) {
+ partitioner.open(
+ sinkContext.getParallelInstanceId(),
+ sinkContext.getNumberOfParallelInstances());
+ }
+ valueSerialization.open(context);
+ }
+
+ private Integer extractPartition(
+ ChangeEvent changeEvent, byte[] valueSerialized, int[] partitions)
{
+ if (partitioner != null) {
+ return partitioner.partition(
+ changeEvent,
+ null,
+ valueSerialized,
+ changeEvent.tableId().toString(),
+ partitions);
+ }
+ return null;
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/resources/META-INF/services/org.apache.flink.cdc.common.factories.Factory
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/resources/META-INF/services/org.apache.flink.cdc.common.factories.Factory
new file mode 100644
index 000000000..fa13cd600
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/main/resources/META-INF/services/org.apache.flink.cdc.common.factories.Factory
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+org.apache.flink.cdc.connectors.kafka.sink.KafkaDataSinkFactory
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/MockInitializationContext.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/MockInitializationContext.java
new file mode 100644
index 000000000..deb60064a
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/MockInitializationContext.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.json;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.metrics.groups.UnregisteredMetricsGroup;
+import org.apache.flink.util.SimpleUserCodeClassLoader;
+import org.apache.flink.util.UserCodeClassLoader;
+
+/** A mock context for serialization schema testing. */
+public class MockInitializationContext implements
SerializationSchema.InitializationContext {
+
+ @Override
+ public MetricGroup getMetricGroup() {
+ return new UnregisteredMetricsGroup();
+ }
+
+ @Override
+ public UserCodeClassLoader getUserCodeClassLoader() {
+ return
SimpleUserCodeClassLoader.create(MockInitializationContext.class.getClassLoader());
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/TableSchemaInfoTest.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/TableSchemaInfoTest.java
new file mode 100644
index 000000000..73f8ad64d
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/TableSchemaInfoTest.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.json;
+
+import org.apache.flink.cdc.common.data.DecimalData;
+import org.apache.flink.cdc.common.data.LocalZonedTimestampData;
+import org.apache.flink.cdc.common.data.TimestampData;
+import org.apache.flink.cdc.common.data.binary.BinaryRecordData;
+import org.apache.flink.cdc.common.data.binary.BinaryStringData;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.DataType;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.types.RowKind;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.ZoneId;
+
+/** Tests for {@link TableSchemaInfo}. */
+public class TableSchemaInfoTest {
+
+ @Test
+ public void testGetRowDataFromRecordData() {
+ Schema schema =
+ Schema.newBuilder()
+ .physicalColumn(
+ "col1",
+
org.apache.flink.cdc.common.types.DataTypes.STRING().notNull())
+ .physicalColumn(
+ "boolean",
org.apache.flink.cdc.common.types.DataTypes.BOOLEAN())
+ .physicalColumn(
+ "binary",
org.apache.flink.cdc.common.types.DataTypes.BINARY(3))
+ .physicalColumn(
+ "varbinary",
+
org.apache.flink.cdc.common.types.DataTypes.VARBINARY(10))
+ .physicalColumn(
+ "bytes",
org.apache.flink.cdc.common.types.DataTypes.BYTES())
+ .physicalColumn(
+ "tinyint",
org.apache.flink.cdc.common.types.DataTypes.TINYINT())
+ .physicalColumn(
+ "smallint",
org.apache.flink.cdc.common.types.DataTypes.SMALLINT())
+ .physicalColumn("int",
org.apache.flink.cdc.common.types.DataTypes.INT())
+ .physicalColumn(
+ "big_int",
org.apache.flink.cdc.common.types.DataTypes.BIGINT())
+ .physicalColumn(
+ "float",
org.apache.flink.cdc.common.types.DataTypes.FLOAT())
+ .physicalColumn(
+ "double",
org.apache.flink.cdc.common.types.DataTypes.DOUBLE())
+ .physicalColumn(
+ "decimal",
+
org.apache.flink.cdc.common.types.DataTypes.DECIMAL(6, 3))
+ .physicalColumn("char",
org.apache.flink.cdc.common.types.DataTypes.CHAR(5))
+ .physicalColumn(
+ "varchar",
org.apache.flink.cdc.common.types.DataTypes.VARCHAR(10))
+ .physicalColumn(
+ "string",
org.apache.flink.cdc.common.types.DataTypes.STRING())
+ .physicalColumn("date",
org.apache.flink.cdc.common.types.DataTypes.DATE())
+ .physicalColumn("time",
org.apache.flink.cdc.common.types.DataTypes.TIME())
+ .physicalColumn(
+ "time_with_precision",
+
org.apache.flink.cdc.common.types.DataTypes.TIME(6))
+ .physicalColumn(
+ "timestamp",
+
org.apache.flink.cdc.common.types.DataTypes.TIMESTAMP())
+ .physicalColumn(
+ "timestamp_with_precision",
+
org.apache.flink.cdc.common.types.DataTypes.TIMESTAMP(3))
+ .physicalColumn(
+ "timestamp_ltz",
+
org.apache.flink.cdc.common.types.DataTypes.TIMESTAMP_LTZ())
+ .physicalColumn(
+ "timestamp_ltz_with_precision",
+
org.apache.flink.cdc.common.types.DataTypes.TIMESTAMP_LTZ(3))
+ .physicalColumn(
+ "null_string",
org.apache.flink.cdc.common.types.DataTypes.STRING())
+ .primaryKey("col1")
+ .build();
+ TableSchemaInfo tableSchemaInfo = new TableSchemaInfo(schema, null,
ZoneId.of("UTC+8"));
+ Object[] testData =
+ new Object[] {
+ BinaryStringData.fromString("pk"),
+ true,
+ new byte[] {1, 2},
+ new byte[] {3, 4},
+ new byte[] {5, 6, 7},
+ (byte) 1,
+ (short) 2,
+ 3,
+ 4L,
+ 5.1f,
+ 6.2,
+ DecimalData.fromBigDecimal(new BigDecimal("7.123"), 6, 3),
+ BinaryStringData.fromString("test1"),
+ BinaryStringData.fromString("test2"),
+ BinaryStringData.fromString("test3"),
+ 100,
+ 200,
+ 300,
+ TimestampData.fromTimestamp(
+ java.sql.Timestamp.valueOf("2023-01-01
00:00:00.000")),
+
TimestampData.fromTimestamp(java.sql.Timestamp.valueOf("2023-01-01 00:00:00")),
+
LocalZonedTimestampData.fromInstant(Instant.parse("2023-01-01T00:00:00.000Z")),
+
LocalZonedTimestampData.fromInstant(Instant.parse("2023-01-01T00:00:00.000Z")),
+ null
+ };
+ BinaryRecordData recordData =
+ new
BinaryRecordDataGenerator(schema.getColumnDataTypes().toArray(new DataType[0]))
+ .generate(testData);
+
+ Assertions.assertEquals(
+ GenericRowData.ofKind(
+ RowKind.INSERT,
+
org.apache.flink.table.data.binary.BinaryStringData.fromString("pk"),
+ true,
+ new byte[] {1, 2},
+ new byte[] {3, 4},
+ new byte[] {5, 6, 7},
+ (byte) 1,
+ (short) 2,
+ 3,
+ 4L,
+ 5.1f,
+ 6.2,
+ org.apache.flink.table.data.DecimalData.fromBigDecimal(
+ new BigDecimal("7.123"), 6, 3),
+
org.apache.flink.table.data.binary.BinaryStringData.fromString("test1"),
+
org.apache.flink.table.data.binary.BinaryStringData.fromString("test2"),
+
org.apache.flink.table.data.binary.BinaryStringData.fromString("test3"),
+ 100,
+ 200,
+ 300,
+
org.apache.flink.table.data.TimestampData.fromTimestamp(
+ Timestamp.valueOf("2023-01-01 00:00:00.000")),
+
org.apache.flink.table.data.TimestampData.fromTimestamp(
+ Timestamp.valueOf("2023-01-01 00:00:00")),
+ // plus 8 hours.
+ org.apache.flink.table.data.TimestampData.fromInstant(
+ Instant.parse("2023-01-01T08:00:00.000Z")),
+ org.apache.flink.table.data.TimestampData.fromInstant(
+ Instant.parse("2023-01-01T08:00:00.000Z")),
+ null),
+ tableSchemaInfo.getRowDataFromRecordData(recordData));
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/canal/CanalJsonSerializationSchemaTest.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/canal/CanalJsonSerializationSchemaTest.java
new file mode 100644
index 000000000..52181fd6b
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/canal/CanalJsonSerializationSchemaTest.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.json.canal;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.cdc.common.data.binary.BinaryStringData;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.common.types.RowType;
+import org.apache.flink.cdc.connectors.kafka.json.ChangeLogJsonFormatFactory;
+import org.apache.flink.cdc.connectors.kafka.json.JsonSerializationType;
+import org.apache.flink.cdc.connectors.kafka.json.MockInitializationContext;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.util.jackson.JacksonMapperFactory;
+
+import
org.apache.flink.shaded.jackson2.com.fasterxml.jackson.core.JsonGenerator;
+import
org.apache.flink.shaded.jackson2.com.fasterxml.jackson.databind.JsonNode;
+import
org.apache.flink.shaded.jackson2.com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link CanalJsonSerializationSchema}. */
+public class CanalJsonSerializationSchemaTest {
+
+ public static final TableId TABLE_1 =
+ TableId.tableId("default_namespace", "default_schema", "table1");
+
+ @Test
+ public void testSerialize() throws Exception {
+ ObjectMapper mapper =
+ JacksonMapperFactory.createObjectMapper()
+
.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, false);
+ SerializationSchema<Event> serializationSchema =
+ ChangeLogJsonFormatFactory.createSerializationSchema(
+ new Configuration(), JsonSerializationType.CANAL_JSON);
+ serializationSchema.open(new MockInitializationContext());
+
+ // create table
+ Schema schema =
+ Schema.newBuilder()
+ .physicalColumn("col1", DataTypes.STRING())
+ .physicalColumn("col2", DataTypes.STRING())
+ .primaryKey("col1")
+ .build();
+ CreateTableEvent createTableEvent = new CreateTableEvent(TABLE_1,
schema);
+ Assertions.assertNull(serializationSchema.serialize(createTableEvent));
+
+ // insert
+ BinaryRecordDataGenerator generator =
+ new BinaryRecordDataGenerator(RowType.of(DataTypes.STRING(),
DataTypes.STRING()));
+ DataChangeEvent insertEvent1 =
+ DataChangeEvent.insertEvent(
+ TABLE_1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("1"),
+ BinaryStringData.fromString("1")
+ }));
+ JsonNode expected =
+ mapper.readTree(
+
"{\"old\":null,\"data\":[{\"col1\":\"1\",\"col2\":\"1\"}],\"type\":\"INSERT\"}");
+ JsonNode actual =
mapper.readTree(serializationSchema.serialize(insertEvent1));
+ Assertions.assertEquals(expected, actual);
+
+ DataChangeEvent insertEvent2 =
+ DataChangeEvent.insertEvent(
+ TABLE_1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("2"),
+ BinaryStringData.fromString("2")
+ }));
+ expected =
+ mapper.readTree(
+
"{\"old\":null,\"data\":[{\"col1\":\"2\",\"col2\":\"2\"}],\"type\":\"INSERT\"}");
+ actual = mapper.readTree(serializationSchema.serialize(insertEvent2));
+ Assertions.assertEquals(expected, actual);
+
+ DataChangeEvent deleteEvent =
+ DataChangeEvent.deleteEvent(
+ TABLE_1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("2"),
+ BinaryStringData.fromString("2")
+ }));
+ expected =
+ mapper.readTree(
+
"{\"old\":[{\"col1\":\"2\",\"col2\":\"2\"}],\"data\":null,\"type\":\"DELETE\"}");
+ actual = mapper.readTree(serializationSchema.serialize(deleteEvent));
+ Assertions.assertEquals(expected, actual);
+
+ DataChangeEvent updateEvent =
+ DataChangeEvent.updateEvent(
+ TABLE_1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("1"),
+ BinaryStringData.fromString("1")
+ }),
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("1"),
+ BinaryStringData.fromString("x")
+ }));
+ expected =
+ mapper.readTree(
+
"{\"old\":[{\"col1\":\"1\",\"col2\":\"1\"}],\"data\":[{\"col1\":\"1\",\"col2\":\"x\"}],\"type\":\"UPDATE\"}");
+ actual = mapper.readTree(serializationSchema.serialize(updateEvent));
+ Assertions.assertEquals(expected, actual);
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/debezium/DebeziumJsonSerializationSchemaTest.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/debezium/DebeziumJsonSerializationSchemaTest.java
new file mode 100644
index 000000000..7a47bf8bd
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/json/debezium/DebeziumJsonSerializationSchemaTest.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.json.debezium;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.cdc.common.data.binary.BinaryStringData;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.common.types.RowType;
+import org.apache.flink.cdc.connectors.kafka.json.ChangeLogJsonFormatFactory;
+import org.apache.flink.cdc.connectors.kafka.json.JsonSerializationType;
+import org.apache.flink.cdc.connectors.kafka.json.MockInitializationContext;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.util.jackson.JacksonMapperFactory;
+
+import
org.apache.flink.shaded.jackson2.com.fasterxml.jackson.core.JsonGenerator;
+import
org.apache.flink.shaded.jackson2.com.fasterxml.jackson.databind.JsonNode;
+import
org.apache.flink.shaded.jackson2.com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link DebeziumJsonSerializationSchema}. */
+public class DebeziumJsonSerializationSchemaTest {
+
+ public static final TableId TABLE_1 =
+ TableId.tableId("default_namespace", "default_schema", "table1");
+
+ @Test
+ public void testSerialize() throws Exception {
+ ObjectMapper mapper =
+ JacksonMapperFactory.createObjectMapper()
+
.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, false);
+ SerializationSchema<Event> serializationSchema =
+ ChangeLogJsonFormatFactory.createSerializationSchema(
+ new Configuration(),
JsonSerializationType.DEBEZIUM_JSON);
+ serializationSchema.open(new MockInitializationContext());
+ // create table
+ Schema schema =
+ Schema.newBuilder()
+ .physicalColumn("col1", DataTypes.STRING())
+ .physicalColumn("col2", DataTypes.STRING())
+ .primaryKey("col1")
+ .build();
+ CreateTableEvent createTableEvent = new CreateTableEvent(TABLE_1,
schema);
+ Assertions.assertNull(serializationSchema.serialize(createTableEvent));
+ BinaryRecordDataGenerator generator =
+ new BinaryRecordDataGenerator(RowType.of(DataTypes.STRING(),
DataTypes.STRING()));
+ // insert
+ DataChangeEvent insertEvent1 =
+ DataChangeEvent.insertEvent(
+ TABLE_1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("1"),
+ BinaryStringData.fromString("1")
+ }));
+ JsonNode expected =
+ mapper.readTree(
+
"{\"before\":null,\"after\":{\"col1\":\"1\",\"col2\":\"1\"},\"op\":\"c\"}");
+ JsonNode actual =
mapper.readTree(serializationSchema.serialize(insertEvent1));
+ Assertions.assertEquals(expected, actual);
+ DataChangeEvent insertEvent2 =
+ DataChangeEvent.insertEvent(
+ TABLE_1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("2"),
+ BinaryStringData.fromString("2")
+ }));
+ expected =
+ mapper.readTree(
+
"{\"before\":null,\"after\":{\"col1\":\"2\",\"col2\":\"2\"},\"op\":\"c\"}");
+ actual = mapper.readTree(serializationSchema.serialize(insertEvent2));
+ Assertions.assertEquals(expected, actual);
+ DataChangeEvent deleteEvent =
+ DataChangeEvent.deleteEvent(
+ TABLE_1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("2"),
+ BinaryStringData.fromString("2")
+ }));
+ expected =
+ mapper.readTree(
+
"{\"before\":{\"col1\":\"2\",\"col2\":\"2\"},\"after\":null,\"op\":\"d\"}");
+ actual = mapper.readTree(serializationSchema.serialize(deleteEvent));
+ Assertions.assertEquals(expected, actual);
+ DataChangeEvent updateEvent =
+ DataChangeEvent.updateEvent(
+ TABLE_1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("1"),
+ BinaryStringData.fromString("1")
+ }),
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("1"),
+ BinaryStringData.fromString("x")
+ }));
+ expected =
+ mapper.readTree(
+
"{\"before\":{\"col1\":\"1\",\"col2\":\"1\"},\"after\":{\"col1\":\"1\",\"col2\":\"x\"},\"op\":\"u\"}");
+ actual = mapper.readTree(serializationSchema.serialize(updateEvent));
+ Assertions.assertEquals(expected, actual);
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkFactoryTest.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkFactoryTest.java
new file mode 100644
index 000000000..1f1957bea
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkFactoryTest.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.sink;
+
+import org.apache.flink.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.factories.DataSinkFactory;
+import org.apache.flink.cdc.common.factories.FactoryHelper;
+import org.apache.flink.cdc.common.sink.DataSink;
+import org.apache.flink.cdc.composer.utils.FactoryDiscoveryUtils;
+
+import org.apache.flink.shaded.guava31.com.google.common.collect.ImmutableMap;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link KafkaDataSinkFactory}. */
+public class KafkaDataSinkFactoryTest {
+
+ @Test
+ public void testCreateDataSink() {
+ DataSinkFactory sinkFactory =
+ FactoryDiscoveryUtils.getFactoryByIdentifier("kafka",
DataSinkFactory.class);
+ Assertions.assertTrue(sinkFactory instanceof KafkaDataSinkFactory);
+
+ Configuration conf = Configuration.fromMap(ImmutableMap.<String,
String>builder().build());
+ DataSink dataSink =
+ sinkFactory.createDataSink(
+ new FactoryHelper.DefaultContext(
+ conf, conf,
Thread.currentThread().getContextClassLoader()));
+ Assertions.assertTrue(dataSink instanceof KafkaDataSink);
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkITCase.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkITCase.java
new file mode 100644
index 000000000..2e2656596
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaDataSinkITCase.java
@@ -0,0 +1,495 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.sink;
+
+import org.apache.flink.api.common.restartstrategy.RestartStrategies;
+import org.apache.flink.cdc.common.configuration.Configuration;
+import org.apache.flink.cdc.common.data.binary.BinaryStringData;
+import org.apache.flink.cdc.common.event.AddColumnEvent;
+import org.apache.flink.cdc.common.event.CreateTableEvent;
+import org.apache.flink.cdc.common.event.DataChangeEvent;
+import org.apache.flink.cdc.common.event.DropColumnEvent;
+import org.apache.flink.cdc.common.event.Event;
+import org.apache.flink.cdc.common.event.RenameColumnEvent;
+import org.apache.flink.cdc.common.event.TableId;
+import org.apache.flink.cdc.common.factories.FactoryHelper;
+import org.apache.flink.cdc.common.schema.Column;
+import org.apache.flink.cdc.common.schema.Schema;
+import org.apache.flink.cdc.common.sink.FlinkSinkProvider;
+import org.apache.flink.cdc.common.types.DataTypes;
+import org.apache.flink.cdc.common.types.RowType;
+import org.apache.flink.cdc.connectors.kafka.json.JsonSerializationType;
+import org.apache.flink.cdc.runtime.typeutils.BinaryRecordDataGenerator;
+import org.apache.flink.cdc.runtime.typeutils.EventTypeInfo;
+import org.apache.flink.streaming.api.datastream.DataStream;
+import org.apache.flink.streaming.api.environment.LocalStreamEnvironment;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.util.TestLogger;
+import org.apache.flink.util.jackson.JacksonMapperFactory;
+
+import
org.apache.flink.shaded.jackson2.com.fasterxml.jackson.core.JsonGenerator;
+import
org.apache.flink.shaded.jackson2.com.fasterxml.jackson.databind.JsonNode;
+import
org.apache.flink.shaded.jackson2.com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.kafka.clients.CommonClientConfigs;
+import org.apache.kafka.clients.admin.AdminClient;
+import org.apache.kafka.clients.admin.CreateTopicsResult;
+import org.apache.kafka.clients.admin.DeleteTopicsResult;
+import org.apache.kafka.clients.admin.NewTopic;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.KafkaContainer;
+import org.testcontainers.containers.Network;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Collectors;
+
+import static org.apache.flink.util.DockerImageVersions.KAFKA;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+
+/** Tests for using {@link KafkaDataSink} writing to a Kafka cluster. */
+class KafkaDataSinkITCase extends TestLogger {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(KafkaDataSinkITCase.class);
+ private static final String INTER_CONTAINER_KAFKA_ALIAS = "kafka";
+ private static final Network NETWORK = Network.newNetwork();
+ private static final int ZK_TIMEOUT_MILLIS = 30000;
+ private static final short TOPIC_REPLICATION_FACTOR = 1;
+ private static AdminClient admin;
+
+ private String topic;
+
+ private TableId table1;
+
+ public static final KafkaContainer KAFKA_CONTAINER =
+ KafkaUtil.createKafkaContainer(KAFKA, LOG)
+ .withEmbeddedZookeeper()
+ .withNetwork(NETWORK)
+ .withNetworkAliases(INTER_CONTAINER_KAFKA_ALIAS);
+
+ @BeforeAll
+ public static void setupAdmin() {
+ KAFKA_CONTAINER.start();
+ Map<String, Object> properties = new HashMap<>();
+ properties.put(
+ CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG,
+ KAFKA_CONTAINER.getBootstrapServers());
+ admin = AdminClient.create(properties);
+ }
+
+ @AfterAll
+ public static void teardownAdmin() {
+ admin.close();
+ KAFKA_CONTAINER.stop();
+ }
+
+ @BeforeEach
+ public void setUp() throws ExecutionException, InterruptedException {
+ table1 =
+ TableId.tableId(
+ "default_namespace", "default_schema",
UUID.randomUUID().toString());
+ topic = table1.toString();
+ createTestTopic(topic, 1, TOPIC_REPLICATION_FACTOR);
+ }
+
+ @AfterEach
+ public void tearDown() throws ExecutionException, InterruptedException {
+ deleteTestTopic(topic);
+ }
+
+ private void createTestTopic(String topic, int numPartitions, short
replicationFactor)
+ throws ExecutionException, InterruptedException {
+ final CreateTopicsResult result =
+ admin.createTopics(
+ Collections.singletonList(
+ new NewTopic(topic, numPartitions,
replicationFactor)));
+ result.all().get();
+ }
+
+ private void deleteTestTopic(String topic) throws ExecutionException,
InterruptedException {
+ final DeleteTopicsResult result =
admin.deleteTopics(Collections.singletonList(topic));
+ result.all().get();
+ }
+
+ private List<Event> createSourceEvents() {
+ List<Event> events = new ArrayList<>();
+ // create table
+ Schema schema =
+ Schema.newBuilder()
+ .physicalColumn("col1", DataTypes.STRING())
+ .physicalColumn("col2", DataTypes.STRING())
+ .primaryKey("col1")
+ .build();
+ CreateTableEvent createTableEvent = new CreateTableEvent(table1,
schema);
+ events.add(createTableEvent);
+
+ BinaryRecordDataGenerator generator =
+ new BinaryRecordDataGenerator(RowType.of(DataTypes.STRING(),
DataTypes.STRING()));
+ // insert
+ DataChangeEvent insertEvent1 =
+ DataChangeEvent.insertEvent(
+ table1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("1"),
+ BinaryStringData.fromString("1")
+ }));
+ events.add(insertEvent1);
+ DataChangeEvent insertEvent2 =
+ DataChangeEvent.insertEvent(
+ table1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("2"),
+ BinaryStringData.fromString("2")
+ }));
+ events.add(insertEvent2);
+ DataChangeEvent insertEvent3 =
+ DataChangeEvent.insertEvent(
+ table1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("3"),
+ BinaryStringData.fromString("3")
+ }));
+ events.add(insertEvent3);
+
+ // add column
+ AddColumnEvent.ColumnWithPosition columnWithPosition =
+ new AddColumnEvent.ColumnWithPosition(
+ Column.physicalColumn("col3", DataTypes.STRING()));
+ AddColumnEvent addColumnEvent =
+ new AddColumnEvent(table1,
Collections.singletonList(columnWithPosition));
+ events.add(addColumnEvent);
+
+ // rename column
+ Map<String, String> nameMapping = new HashMap<>();
+ nameMapping.put("col2", "newCol2");
+ nameMapping.put("col3", "newCol3");
+ RenameColumnEvent renameColumnEvent = new RenameColumnEvent(table1,
nameMapping);
+ events.add(renameColumnEvent);
+
+ // drop column
+ DropColumnEvent dropColumnEvent =
+ new DropColumnEvent(table1,
Collections.singletonList("newCol2"));
+ events.add(dropColumnEvent);
+
+ // delete
+ events.add(
+ DataChangeEvent.deleteEvent(
+ table1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("1"),
+ BinaryStringData.fromString("1")
+ })));
+
+ // update
+ events.add(
+ DataChangeEvent.updateEvent(
+ table1,
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("2"),
+ BinaryStringData.fromString("")
+ }),
+ generator.generate(
+ new Object[] {
+ BinaryStringData.fromString("2"),
+ BinaryStringData.fromString("x")
+ })));
+ return events;
+ }
+
+ @Test
+ void testDebeziumJsonFormat() throws Exception {
+ final StreamExecutionEnvironment env = new LocalStreamEnvironment();
+ env.enableCheckpointing(1000L);
+ env.setRestartStrategy(RestartStrategies.noRestart());
+ final DataStream<Event> source =
+ env.fromCollection(createSourceEvents(), new EventTypeInfo());
+ Map<String, String> config = new HashMap<>();
+ Properties properties = getKafkaClientConfiguration();
+ properties.forEach(
+ (key, value) ->
+ config.put(
+ KafkaDataSinkOptions.PROPERTIES_PREFIX +
key.toString(),
+ value.toString()));
+ source.sinkTo(
+ ((FlinkSinkProvider)
+ (new KafkaDataSinkFactory()
+ .createDataSink(
+ new
FactoryHelper.DefaultContext(
+
Configuration.fromMap(config),
+
Configuration.fromMap(new HashMap<>()),
+
this.getClass().getClassLoader()))
+ .getEventSinkProvider()))
+ .getSink());
+ env.execute();
+
+ final List<ConsumerRecord<byte[], byte[]>> collectedRecords =
+ drainAllRecordsFromTopic(topic, false);
+ final long recordsCount = 5;
+ assertThat(recordsCount).isEqualTo(collectedRecords.size());
+ ObjectMapper mapper =
+ JacksonMapperFactory.createObjectMapper()
+
.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, false);
+ List<JsonNode> expected =
+ Arrays.asList(
+ mapper.readTree(
+
"{\"before\":null,\"after\":{\"col1\":\"1\",\"col2\":\"1\"},\"op\":\"c\"}"),
+ mapper.readTree(
+
"{\"before\":null,\"after\":{\"col1\":\"2\",\"col2\":\"2\"},\"op\":\"c\"}"),
+ mapper.readTree(
+
"{\"before\":null,\"after\":{\"col1\":\"3\",\"col2\":\"3\"},\"op\":\"c\"}"),
+ mapper.readTree(
+
"{\"before\":{\"col1\":\"1\",\"newCol3\":\"1\"},\"after\":null,\"op\":\"d\"}"),
+ mapper.readTree(
+
"{\"before\":{\"col1\":\"2\",\"newCol3\":\"\"},\"after\":{\"col1\":\"2\",\"newCol3\":\"x\"},\"op\":\"u\"}"));
+ assertThat(deserializeValues(collectedRecords)).containsAll(expected);
+ checkProducerLeak();
+ }
+
+ @Test
+ void testCanalJsonFormat() throws Exception {
+ final StreamExecutionEnvironment env = new LocalStreamEnvironment();
+ env.enableCheckpointing(1000L);
+ env.setRestartStrategy(RestartStrategies.noRestart());
+ final DataStream<Event> source =
+ env.fromCollection(createSourceEvents(), new EventTypeInfo());
+ Map<String, String> config = new HashMap<>();
+ Properties properties = getKafkaClientConfiguration();
+ properties.forEach(
+ (key, value) ->
+ config.put(
+ KafkaDataSinkOptions.PROPERTIES_PREFIX +
key.toString(),
+ value.toString()));
+ config.put(
+ KafkaDataSinkOptions.VALUE_FORMAT.key(),
+ JsonSerializationType.CANAL_JSON.toString());
+ source.sinkTo(
+ ((FlinkSinkProvider)
+ (new KafkaDataSinkFactory()
+ .createDataSink(
+ new
FactoryHelper.DefaultContext(
+
Configuration.fromMap(config),
+
Configuration.fromMap(new HashMap<>()),
+
this.getClass().getClassLoader()))
+ .getEventSinkProvider()))
+ .getSink());
+ env.execute();
+
+ final List<ConsumerRecord<byte[], byte[]>> collectedRecords =
+ drainAllRecordsFromTopic(topic, false);
+ final long recordsCount = 5;
+ assertThat(recordsCount).isEqualTo(collectedRecords.size());
+ for (ConsumerRecord<byte[], byte[]> consumerRecord : collectedRecords)
{
+ assertThat(
+ consumerRecord
+ .headers()
+ .headers(
+
PipelineKafkaRecordSerializationSchema
+ .TABLE_NAME_HEADER_KEY)
+ .iterator())
+ .isExhausted();
+ }
+ ObjectMapper mapper =
+ JacksonMapperFactory.createObjectMapper()
+
.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, false);
+ List<JsonNode> expected =
+ Arrays.asList(
+ mapper.readTree(
+
"{\"old\":null,\"data\":[{\"col1\":\"1\",\"col2\":\"1\"}],\"type\":\"INSERT\"}"),
+ mapper.readTree(
+
"{\"old\":null,\"data\":[{\"col1\":\"2\",\"col2\":\"2\"}],\"type\":\"INSERT\"}"),
+ mapper.readTree(
+
"{\"old\":null,\"data\":[{\"col1\":\"3\",\"col2\":\"3\"}],\"type\":\"INSERT\"}"),
+ mapper.readTree(
+
"{\"old\":[{\"col1\":\"1\",\"newCol3\":\"1\"}],\"data\":null,\"type\":\"DELETE\"}"),
+ mapper.readTree(
+
"{\"old\":[{\"col1\":\"2\",\"newCol3\":\"\"}],\"data\":[{\"col1\":\"2\",\"newCol3\":\"x\"}],\"type\":\"UPDATE\"}"));
+ assertThat(deserializeValues(collectedRecords)).containsAll(expected);
+ checkProducerLeak();
+ }
+
+ @Test
+ void testTopicAndHeaderOption() throws Exception {
+ final StreamExecutionEnvironment env = new LocalStreamEnvironment();
+ env.enableCheckpointing(1000L);
+ env.setRestartStrategy(RestartStrategies.noRestart());
+ final DataStream<Event> source =
+ env.fromCollection(createSourceEvents(), new EventTypeInfo());
+ Map<String, String> config = new HashMap<>();
+ config.put(KafkaDataSinkOptions.TOPIC.key(), "test_topic");
+
config.put(KafkaDataSinkOptions.SINK_ADD_TABLEID_TO_HEADER_ENABLED.key(),
"true");
+ Properties properties = getKafkaClientConfiguration();
+ properties.forEach(
+ (key, value) ->
+ config.put(
+ KafkaDataSinkOptions.PROPERTIES_PREFIX +
key.toString(),
+ value.toString()));
+ source.sinkTo(
+ ((FlinkSinkProvider)
+ (new KafkaDataSinkFactory()
+ .createDataSink(
+ new
FactoryHelper.DefaultContext(
+
Configuration.fromMap(config),
+
Configuration.fromMap(new HashMap<>()),
+
this.getClass().getClassLoader()))
+ .getEventSinkProvider()))
+ .getSink());
+ env.execute();
+
+ final List<ConsumerRecord<byte[], byte[]>> collectedRecords =
+ drainAllRecordsFromTopic("test_topic", false);
+ final long recordsCount = 5;
+ assertThat(recordsCount).isEqualTo(collectedRecords.size());
+ for (ConsumerRecord<byte[], byte[]> consumerRecord : collectedRecords)
{
+ assertThat(
+ new String(
+ consumerRecord
+ .headers()
+ .headers(
+
PipelineKafkaRecordSerializationSchema
+
.NAMESPACE_HEADER_KEY)
+ .iterator()
+ .next()
+ .value()))
+ .isEqualTo(table1.getNamespace());
+ assertThat(
+ new String(
+ consumerRecord
+ .headers()
+ .headers(
+
PipelineKafkaRecordSerializationSchema
+
.SCHEMA_NAME_HEADER_KEY)
+ .iterator()
+ .next()
+ .value()))
+ .isEqualTo(table1.getSchemaName());
+ assertThat(
+ new String(
+ consumerRecord
+ .headers()
+ .headers(
+
PipelineKafkaRecordSerializationSchema
+
.TABLE_NAME_HEADER_KEY)
+ .iterator()
+ .next()
+ .value()))
+ .isEqualTo(table1.getTableName());
+ }
+ ObjectMapper mapper =
+ JacksonMapperFactory.createObjectMapper()
+
.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, false);
+ List<JsonNode> expected =
+ Arrays.asList(
+ mapper.readTree(
+
"{\"before\":null,\"after\":{\"col1\":\"1\",\"col2\":\"1\"},\"op\":\"c\"}"),
+ mapper.readTree(
+
"{\"before\":null,\"after\":{\"col1\":\"2\",\"col2\":\"2\"},\"op\":\"c\"}"),
+ mapper.readTree(
+
"{\"before\":null,\"after\":{\"col1\":\"3\",\"col2\":\"3\"},\"op\":\"c\"}"),
+ mapper.readTree(
+
"{\"before\":{\"col1\":\"1\",\"newCol3\":\"1\"},\"after\":null,\"op\":\"d\"}"),
+ mapper.readTree(
+
"{\"before\":{\"col1\":\"2\",\"newCol3\":\"\"},\"after\":{\"col1\":\"2\",\"newCol3\":\"x\"},\"op\":\"u\"}"));
+ assertThat(deserializeValues(collectedRecords)).containsAll(expected);
+ checkProducerLeak();
+ }
+
+ private List<ConsumerRecord<byte[], byte[]>> drainAllRecordsFromTopic(
+ String topic, boolean committed) {
+ Properties properties = getKafkaClientConfiguration();
+ return KafkaUtil.drainAllRecordsFromTopic(topic, properties,
committed);
+ }
+
+ private void checkProducerLeak() throws InterruptedException {
+ List<Map.Entry<Thread, StackTraceElement[]>> leaks = null;
+ for (int tries = 0; tries < 10; tries++) {
+ leaks =
+ Thread.getAllStackTraces().entrySet().stream()
+ .filter(this::findAliveKafkaThread)
+ .collect(Collectors.toList());
+ if (leaks.isEmpty()) {
+ return;
+ }
+ Thread.sleep(1000);
+ }
+
+ for (Map.Entry<Thread, StackTraceElement[]> leak : leaks) {
+ leak.getKey().stop();
+ }
+ fail(
+ "Detected producer leaks:\n"
+ +
leaks.stream().map(this::format).collect(Collectors.joining("\n\n")));
+ }
+
+ private static List<JsonNode>
deserializeValues(List<ConsumerRecord<byte[], byte[]>> records)
+ throws IOException {
+ ObjectMapper mapper =
+ JacksonMapperFactory.createObjectMapper()
+
.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, false);
+ List<JsonNode> result = new ArrayList<>();
+ for (ConsumerRecord<byte[], byte[]> record : records) {
+ result.add(mapper.readTree(record.value()));
+ }
+ return result;
+ }
+
+ private static Properties getKafkaClientConfiguration() {
+ final Properties standardProps = new Properties();
+ standardProps.put("bootstrap.servers",
KAFKA_CONTAINER.getBootstrapServers());
+ standardProps.put("group.id", UUID.randomUUID().toString());
+ standardProps.put("enable.auto.commit", false);
+ standardProps.put("auto.offset.reset", "earliest");
+ standardProps.put("max.partition.fetch.bytes", 256);
+ standardProps.put("zookeeper.session.timeout.ms", ZK_TIMEOUT_MILLIS);
+ standardProps.put("zookeeper.connection.timeout.ms",
ZK_TIMEOUT_MILLIS);
+ return standardProps;
+ }
+
+ private String format(Map.Entry<Thread, StackTraceElement[]> leak) {
+ String stackTrace =
+ Arrays.stream(leak.getValue())
+ .map(StackTraceElement::toString)
+ .collect(Collectors.joining("\n"));
+ return leak.getKey().getName() + ":\n" + stackTrace;
+ }
+
+ private boolean findAliveKafkaThread(Map.Entry<Thread,
StackTraceElement[]> threadStackTrace) {
+ return threadStackTrace.getKey().getState() != Thread.State.TERMINATED
+ &&
threadStackTrace.getKey().getName().contains("kafka-producer-network-thread");
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaUtil.java
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaUtil.java
new file mode 100644
index 000000000..d1f36f657
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/java/org/apache/flink/cdc/connectors/kafka/sink/KafkaUtil.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.cdc.connectors.kafka.sink;
+
+import org.apache.flink.util.StringUtils;
+
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.ConsumerRecords;
+import org.apache.kafka.clients.consumer.KafkaConsumer;
+import org.apache.kafka.common.KafkaException;
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.serialization.ByteArrayDeserializer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.KafkaContainer;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.utility.DockerImageName;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/** Collection of methods to interact with a Kafka cluster. */
+public class KafkaUtil {
+
+ private static final Logger LOG = LoggerFactory.getLogger(KafkaUtil.class);
+ private static final Duration CONSUMER_POLL_DURATION =
Duration.ofSeconds(1);
+
+ private KafkaUtil() {}
+
+ /**
+ * This method helps to set commonly used Kafka configurations and aligns
the internal Kafka log
+ * levels with the ones used by the capturing logger.
+ *
+ * @param dockerImageVersion describing the Kafka image
+ * @param logger to derive the log level from
+ * @return configured Kafka container
+ */
+ public static KafkaContainer createKafkaContainer(String
dockerImageVersion, Logger logger) {
+ return createKafkaContainer(dockerImageVersion, logger, null);
+ }
+
+ /**
+ * This method helps to set commonly used Kafka configurations and aligns
the internal Kafka log
+ * levels with the ones used by the capturing logger, and set the prefix
of logger.
+ */
+ public static KafkaContainer createKafkaContainer(
+ String dockerImageVersion, Logger logger, String loggerPrefix) {
+ String logLevel;
+ if (logger.isTraceEnabled()) {
+ logLevel = "TRACE";
+ } else if (logger.isDebugEnabled()) {
+ logLevel = "DEBUG";
+ } else if (logger.isInfoEnabled()) {
+ logLevel = "INFO";
+ } else if (logger.isWarnEnabled()) {
+ logLevel = "WARN";
+ } else if (logger.isErrorEnabled()) {
+ logLevel = "ERROR";
+ } else {
+ logLevel = "OFF";
+ }
+
+ Slf4jLogConsumer logConsumer = new Slf4jLogConsumer(logger);
+ if (!StringUtils.isNullOrWhitespaceOnly(loggerPrefix)) {
+ logConsumer.withPrefix(loggerPrefix);
+ }
+ return new KafkaContainer(DockerImageName.parse(dockerImageVersion))
+ .withEnv("KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR", "1")
+ .withEnv("KAFKA_TRANSACTION_STATE_LOG_MIN_ISR", "1")
+ .withEnv("KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR", "1")
+ .withEnv("KAFKA_CONFLUENT_SUPPORT_METRICS_ENABLE", "false")
+ .withEnv("KAFKA_LOG4J_ROOT_LOGLEVEL", logLevel)
+ .withEnv("KAFKA_LOG4J_LOGGERS", "state.change.logger=" +
logLevel)
+ .withEnv("KAFKA_CONFLUENT_SUPPORT_METRICS_ENABLE", "false")
+ .withEnv(
+ "KAFKA_TRANSACTION_MAX_TIMEOUT_MS",
+ String.valueOf(Duration.ofHours(2).toMillis()))
+ .withEnv("KAFKA_LOG4J_TOOLS_ROOT_LOGLEVEL", logLevel)
+ .withLogConsumer(logConsumer);
+ }
+
+ /**
+ * Drain all records available from the given topic from the beginning
until the current highest
+ * offset.
+ *
+ * <p>This method will fetch the latest offsets for the partitions once
and only return records
+ * until that point.
+ *
+ * @param topic to fetch from
+ * @param properties used to configure the created {@link KafkaConsumer}
+ * @param committed determines the mode {@link
ConsumerConfig#ISOLATION_LEVEL_CONFIG} with which
+ * the consumer reads the records.
+ * @return all {@link ConsumerRecord} in the topic
+ * @throws KafkaException
+ */
+ public static List<ConsumerRecord<byte[], byte[]>>
drainAllRecordsFromTopic(
+ String topic, Properties properties, boolean committed) throws
KafkaException {
+ final Properties consumerConfig = new Properties();
+ consumerConfig.putAll(properties);
+ consumerConfig.put(
+ ConsumerConfig.ISOLATION_LEVEL_CONFIG,
+ committed ? "read_committed" : "read_uncommitted");
+ return drainAllRecordsFromTopic(topic, consumerConfig);
+ }
+
+ /**
+ * Drain all records available from the given topic from the beginning
until the current highest
+ * offset.
+ *
+ * <p>This method will fetch the latest offsets for the partitions once
and only return records
+ * until that point.
+ *
+ * @param topic to fetch from
+ * @param properties used to configure the created {@link KafkaConsumer}
+ * @return all {@link ConsumerRecord} in the topic
+ * @throws KafkaException
+ */
+ public static List<ConsumerRecord<byte[], byte[]>>
drainAllRecordsFromTopic(
+ String topic, Properties properties) throws KafkaException {
+ final Properties consumerConfig = new Properties();
+ consumerConfig.putAll(properties);
+ consumerConfig.put("key.deserializer",
ByteArrayDeserializer.class.getName());
+ consumerConfig.put("value.deserializer",
ByteArrayDeserializer.class.getName());
+ try (KafkaConsumer<byte[], byte[]> consumer = new
KafkaConsumer<>(consumerConfig)) {
+ Set<TopicPartition> topicPartitions = getAllPartitions(consumer,
topic);
+ Map<TopicPartition, Long> endOffsets =
consumer.endOffsets(topicPartitions);
+ consumer.assign(topicPartitions);
+ consumer.seekToBeginning(topicPartitions);
+
+ final List<ConsumerRecord<byte[], byte[]>> consumerRecords = new
ArrayList<>();
+ while (!topicPartitions.isEmpty()) {
+ ConsumerRecords<byte[], byte[]> records =
consumer.poll(CONSUMER_POLL_DURATION);
+ LOG.debug("Fetched {} records from topic {}.",
records.count(), topic);
+
+ // Remove partitions from polling which have reached its end.
+ final List<TopicPartition> finishedPartitions = new
ArrayList<>();
+ for (final TopicPartition topicPartition : topicPartitions) {
+ final long position = consumer.position(topicPartition);
+ final long endOffset = endOffsets.get(topicPartition);
+ LOG.debug(
+ "Endoffset {} and current position {} for
partition {}",
+ endOffset,
+ position,
+ topicPartition.partition());
+ if (endOffset - position > 0) {
+ continue;
+ }
+ finishedPartitions.add(topicPartition);
+ }
+ if (topicPartitions.removeAll(finishedPartitions)) {
+ consumer.assign(topicPartitions);
+ }
+ for (ConsumerRecord<byte[], byte[]> r : records) {
+ consumerRecords.add(r);
+ }
+ }
+ return consumerRecords;
+ }
+ }
+
+ private static Set<TopicPartition> getAllPartitions(
+ KafkaConsumer<byte[], byte[]> consumer, String topic) {
+ return consumer.partitionsFor(topic).stream()
+ .map(info -> new TopicPartition(info.topic(),
info.partition()))
+ .collect(Collectors.toSet());
+ }
+}
diff --git
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/resources/log4j2-test.properties
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/resources/log4j2-test.properties
new file mode 100644
index 000000000..a9d045e0e
--- /dev/null
+++
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-kafka/src/test/resources/log4j2-test.properties
@@ -0,0 +1,26 @@
+################################################################################
+# Copyright 2023 Ververica Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+# Set root logger level to OFF to not flood build logs
+# set manually to INFO for debugging purposes
+rootLogger.level=INFO
+rootLogger.appenderRef.test.ref = TestLogger
+
+appender.testlogger.name = TestLogger
+appender.testlogger.type = CONSOLE
+appender.testlogger.target = SYSTEM_ERR
+appender.testlogger.layout.type = PatternLayout
+appender.testlogger.layout.pattern = %-4r [%t] %-5p %c - %m%n
diff --git a/flink-cdc-connect/flink-cdc-pipeline-connectors/pom.xml
b/flink-cdc-connect/flink-cdc-pipeline-connectors/pom.xml
index eab79f486..c5265af9f 100644
--- a/flink-cdc-connect/flink-cdc-pipeline-connectors/pom.xml
+++ b/flink-cdc-connect/flink-cdc-pipeline-connectors/pom.xml
@@ -32,6 +32,7 @@ limitations under the License.
<module>flink-cdc-pipeline-connector-mysql</module>
<module>flink-cdc-pipeline-connector-doris</module>
<module>flink-cdc-pipeline-connector-starrocks</module>
+ <module>flink-cdc-pipeline-connector-kafka</module>
</modules>
<dependencies>