手把手教你如何写一个FLink Connecter(一)
--sink connector篇
前言
flink sql目前逐渐被各大公司采用用于做实时数据。相比较代码coding的形式。使用flink sql更加的直观,开发成本更加低廉。目前flink sql中最重要的连接器也支持了各个大型的生态组建。如:Kafka,DynamoDB,Firehose,Kinesis,JDBC,Elasticsearch,写入文件系统,HBase,DataGen,Print 打印测试,BlackHole(写入一个黑洞),Hive(在1.12版本后开始支持)。
但是对于一些有自研组建的公司来说,或者对于connector有特殊要求,比如必须先完成数据库的写入在发送kafka。就需要自己编写flink connector。
目前flink 社区维护的也比较好。对于flink connecter也做了比较详细的说明。可以参考:https://nightlies.apache.org/flink/flink-docs-release-1.17/zh/docs/dev/table/sourcessinks/ 阅读理解,但是相对于官网来说,还不算特别的细致入微。本文将结合Wtable一个非关系型数据库详细的说明SINK和source中look up如何实现。
Wtable介绍
WTable是58架构平台部研发的分布式kv、klist系统。有数据库和表的概念,这与MySQL等关系型数据库类似。
每个数据库由一个唯一bid(business id)标识,bid由架构平台部统一分配.
在WTable中,每条数据都包含rowKey、colKey、value和score等4个属性。
FLink connector概览
上面这个图是又flink官网中下载的自定义connector的流程图。从总体上已经很好的介绍了如何去写一个connector。
下面重点介绍一下sink connector各个继承类都需要做什么
高能预警,下方涉及了大量的代码!!!
DynamicTableSinkFactory类
public class WTableDynamicTableFactory implements DynamicTableSinkFactory {
/**
* 用于创建sink connector的动态Sink描述器。
* @param context 能获取表的各种参数信息.如:表名,字段名称,字段类型等等
*/
@Override
public DynamicTableSink createDynamicTableSink(Context context) {
return null;
}
/**
* 用于返回connector后面写的连接器的名称。注意需要区分大小写
*/
@Override
public String factoryIdentifier() {
return null;
}
/**
* connector中必要的参数,写在这里面的参数。如果在创建表的时候不填写,会报错的参数。
* 如:数据库用户名,密码等等
*/
@Override
public Set<ConfigOption<?>> requiredOptions() {
return null;
}
/**
* connector中可选的参数.
* 如:批量写入时间,条数。
*/
@Override
public Set<ConfigOption<?>> optionalOptions() {
return null;
}
}
DynamicTableSink
public class WTableDynamicTableSink implements DynamicTableSink {
/**
* 约定数据流中支持的数据流类型。INSERT(+I),UPSERT(+u,-u),DELETE(-D)
* 如果在数据处理中只有append,可以只支持INSERT_ONLY,
* 但是作为一个通用的sink connector还是得支持change的方式
* @param requestedMode
*/
@Override
public ChangelogMode getChangelogMode(ChangelogMode requestedMode) {
return null;
}
/**
* 插入数据的方法,需要使用sink Function
*/
@Override
public SinkRuntimeProvider getSinkRuntimeProvider(Context context) {
return null;
}
/**
* 副本创建,只需要简单的调用构造方法
*/
@Override
public DynamicTableSink copy() {
return null;
}
/**
* 给sink connector总结一个名字,用于打印日志。
*/
@Override
public String asSummaryString() {
return null;
}
}
SinkFunction继承
public class WtableSinkFunction<T> extends RichSinkFunction<T> {
/**
* 全局运行一次,用于创建连接器
*/
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
}
/**
* 数据库关闭
*/
@Override
public void close() throws Exception {
super.close();
}
/**
* 数据插入。context为上下文环境, 与前面提到的context不同
*/
@Override
public void invoke(T value, Context context) throws Exception {
}
}
上面三个方法总结了一个sink connector都需要做什么。来完成整个connector的运行。
当然在数据插入的时候,无可厚非的需要设计异步插入,批量插入等等来提高sink connector的执行效率
DynamicTableSinkFactory详细说明
本段要点
- 如果创建sink connector的描述器
- 如果传入连接器需要的参数
代码第一行需要为connector定义名称,这里命名为dw-wtable
public static final String IDENTIFIER = "dw-wtable";
后续的DynamicTableSink是在createDynamicTableSink中创建的。所以在这个方法中需要尽可能的获取后面需要的所有的信息传入到下游中
@Override
public DynamicTableSink createDynamicTableSink(Context context) {
//由flink 提供的工具,来校验create语句是否书写准确
FactoryUtil.TableFactoryHelper helper = createTableFactoryHelper(this, context);
helper.validate();
//获取sink中的需要的参数,传入到下文。用于创建数据库连接器与是否做批量写入的定义
WTableOptions wTableOptions = getConfig(helper);
//ResolvedSchema中保存的是字段的类型,主键等等信息
ResolvedSchema resolvedSchema = context.getCatalogTable().getResolvedSchema();
//由于数据库是K,V形式,需要获取主键信息用于确定rowkey的定义。在语句中为
// PRIMARY key (a) NOT ENFORCED
//todo:为了减少代码量,这里假定主键为一个字段。如果设计到多个字段需要自己定义主键的拼接方法
Optional<UniqueConstraint> primaryKey = resolvedSchema.getPrimaryKey();
//对于主键的检查。如果多余一个主键会抛出异常。这里原因同上面,可以自行约定
validatePrimaryKey(primaryKey);
String pk = primaryKey.get().getColumns().get(0);
//约定好help中字段的序列化方式,flink已经实现好的序列化方式有:Json,DwbeziumJson,CanalJson,MaxwellJson等等
//如果需要特殊的格式需要进行自定义
EncodingFormat<SerializationSchema<RowData>> valueEncodingFormat =
helper.discoverEncodingFormat(SerializationFormatFactory.class, VALUE_FORMAT);
//获取主键的类型
List<String> columnNames = resolvedSchema.getColumnNames();
int pkIndex = columnNames.indexOf(pk);
LogicalType pkDataType =
resolvedSchema.getColumns().get(pkIndex).getDataType().getLogicalType();
// TODO: 为了减少代码量约定为String,如果要支持更多的类型需要对类型进行判断
if (!(pkDataType instanceof VarCharType)) {
throw new RuntimeException("primary key just support string. " + pkDataType);
}
//创建Sink的描述器。
return new WTableDynamicTableSink(
wTableOptions,
resolvedSchema.toPhysicalRowDataType(),
pkIndex,
valueEncodingFormat);
}
上面代码对于flink sink的参数获取做了封装,下面是获取参数的代码
/**
* 这里对于wtable的参数做了封装
* @return wtable连接器参数的实体类
*/
private WTableOptions getConfig(FactoryUtil.TableFactoryHelper helper) {
ReadableConfig options = helper.getOptions();
WTableOptions wTableOptions = new WTableOptions();
wTableOptions.setNc(options.get(WTABLE_NC));
wTableOptions.setBid(options.get(WTABLE_BID));
wTableOptions.setPassword(options.get(WTABLE_PASSWORD));
wTableOptions.setTableId(options.get(WTABLE_TABLEID));
wTableOptions.setCol(options.get(WTABLE_COL));
wTableOptions.setTtl((int) options.get(WTABLE_TTL).getSeconds());
wTableOptions.setBatchSize(options.get(WTABLE_BATCH_SIZE));
wTableOptions.setFlushInterval((int) (options.get(WTABLE_FLUSH_INTERVAL).getSeconds()));
return wTableOptions;
}
WTableOptions 实体类信息,一个标准的bean对象。可以自行通过各种插件完成get,set方法
public class WTableOptions implements Serializable {
private static final long serialVersionUID = 1L;
String nc;
String password;
String col;
int bid;
int tableId;
int ttl;
int flushInterval;
int batchSize;
}
factoryIdentifier方法为connector的名称,只需要注意大小写即可
@Override
public String factoryIdentifier() {
return IDENTIFIER;
}
连接器的参数的传入,这里不做过多的介绍。需要自行根据数据库判断需要传入哪些参数即可。
@Override
public Set<ConfigOption<?>> requiredOptions() {
Set<ConfigOption<?>> requiredOptions = new HashSet<>();
requiredOptions.add(WTABLE_BID);
requiredOptions.add(WTABLE_PASSWORD);
requiredOptions.add(WTABLE_TABLEID);
requiredOptions.add(WTABLE_TTL);
requiredOptions.add(WTABLE_COL);
return requiredOptions;
}
@Override
public Set<ConfigOption<?>> optionalOptions() {
Set<ConfigOption<?>> options = new HashSet<>();
options.add(VALUE_FORMAT);
options.add(WTABLE_NC);
options.add(WTABLE_FLUSH_INTERVAL);
options.add(WTABLE_BATCH_SIZE);
return options;
}
以下是对于参数定义的代码,在sql传入参数是,名字需要定义,且区分大小写
public class WTableConstant {
public static final ConfigOption<String> VALUE_FORMAT =
ConfigOptions.key("value.format")
.stringType()
.defaultValue("json")
.withDescription(
"Defines the format identifier for encoding value data. The identifier is used to discover a suitable format factory.");
public static final ConfigOption<String> WTABLE_NC =
ConfigOptions.key("wtable.nc")
.stringType()
.defaultValue("nameprod.wtable.58dns.org")
.withDescription("wtable password");
public static final ConfigOption<String> WTABLE_PASSWORD =
ConfigOptions.key("wtable.password")
.stringType()
.noDefaultValue()
.withDescription("wtable password");
public static final ConfigOption<String> WTABLE_COL =
ConfigOptions.key("wtable.col")
.stringType()
.noDefaultValue()
.withDescription("wtable password");
public static final ConfigOption<Integer> WTABLE_BID =
ConfigOptions.key("wtable.bid")
.intType()
.noDefaultValue()
.withDescription("wtable bid");
public static final ConfigOption<Integer> WTABLE_TABLEID =
ConfigOptions.key("wtable.tableid")
.intType()
.noDefaultValue()
.withDescription("wtable tableid");
public static final ConfigOption<Duration> WTABLE_TTL =
ConfigOptions.key("wtable.ttl")
.durationType()
.noDefaultValue()
.withDescription("wtable ttl");
public static final ConfigOption<Duration> WTABLE_FLUSH_INTERVAL =
ConfigOptions.key("sink.flush.interval")
.durationType()
.defaultValue(Duration.ofSeconds(1))
.withDescription("wtable flush interval");
public static final ConfigOption<Integer> WTABLE_BATCH_SIZE =
ConfigOptions.key("wtable.batch.size")
.intType()
.defaultValue(100)
.withDescription("wtable batch size");
}
以上为DynamicTableSinkFactory所有需要重写的方法。主要分为
-
创建sink table描述器,尽可能获取所有的信息传入
-
约定connector名称,sql中的语句:‘connector’ = ‘dw-wtable’
-
规定需要传入的参数
DynamicTableSink
上文提到,在createDynamicTableSink,中需要创建DynamicTableSink,下面来说明DynamicTableSink的具体流程.
本段要点:
- 如何约定数据流类型
- 定义数据写入方法
public class WTableDynamicTableSink implements DynamicTableSink {
private WTableOptions wTableOptions;
private DataType physicalDataType;
private int pkIndex;
private EncodingFormat<SerializationSchema<RowData>> valueEncodingFormat;
/**
* sink connector的构造方法。参数可以自己定义
*/
public WTableDynamicTableSink(
WTableOptions wTableOptions,
DataType physicalDataType,
int pkIndex,
EncodingFormat<SerializationSchema<RowData>> valueEncodingFormat) {
this.wTableOptions = wTableOptions;
this.physicalDataType = physicalDataType;
this.pkIndex = pkIndex;
this.valueEncodingFormat = valueEncodingFormat;
}
/**
* 约定数据流的类型。这里支持了 INSERT,UPDATE_AFTER,DELETE等方式。
* 如果有特殊的数据流要求。可以自己更具ChangelogMode来定义
*/
@Override
public ChangelogMode getChangelogMode(ChangelogMode changelogMode) {
return ChangelogMode.upsert();
}
/**
* 数据在这里写入。定义sinkFunction
*/
@Override
public SinkRuntimeProvider getSinkRuntimeProvider(Context context) {
//协定了序列化的描述器。用于后续序列化value
SerializationSchema<RowData> valueEncoder =
valueEncodingFormat.createRuntimeEncoder(context, physicalDataType);
// TODO: 如果需要修改sink并行度,可以在这里加入参数。修改
return SinkFunctionProvider.of(
new WTableSinkFunction(wTableOptions, pkIndex, valueEncoder));
}
@Override
public DynamicTableSink copy() {
return new WTableDynamicTableSink(
wTableOptions, physicalDataType, pkIndex, valueEncodingFormat);
}
@Override
public String asSummaryString() {
return "wtable-sink";
}
}
这个方法主要用于构建sink connector的描述器。较为简单。这里不做更多的介绍
RichSinkFunction
一般而言,所有的数据库都会提供批量插入,修改等方法。用于减少网络交互。提高效率
最后写入的方法与对java功底要求更高一些,需要考虑如何做到批量插入
本段要点:
- 如何约定批量与周期型插入
public class WTableSinkFunction extends RichSinkFunction<RowData> {
// 用于数据写入。String为key。后面为value
// TODO 未来这边应该是 RowData
private final Map<String, Tuple2<Boolean, Object>> reduceBuffer = new HashMap<>();
//创建一个周期型线程池,用于固定时间写入数据
private transient ScheduledExecutorService scheduler;
private transient volatile boolean closed = false;
//线程池调度器
private transient ScheduledFuture<?> scheduledFuture;
private transient volatile Exception flushException;
//wtable客户端
private transient WtableClient wtableClient;
// wtable 配置
private int tableId;
private byte[] colKey;
private int ttl;
private int batchSize;
private int pkIndex;
final AtomicInteger counter = new AtomicInteger();
private WTableOptions wTableOptions;
private SerializationSchema<RowData> valueEncoder;
public WTableSinkFunction(
WTableOptions wTableOptions, int pkIndex, SerializationSchema<RowData> valueEncoder) {
this.wTableOptions = wTableOptions;
this.pkIndex = pkIndex;
this.valueEncoder = valueEncoder;
tableId = wTableOptions.getTableId();
colKey = wTableOptions.getCol().getBytes(StandardCharsets.UTF_8);
ttl = wTableOptions.getTtl();
batchSize = wTableOptions.getBatchSize();
}
/**
* sink的open方法。只在初始化的时候运行一次。
* 周期性线程池调度器在这里创建。并在固定时间进行写入。
*/
@Override
public void open(Configuration parameters) throws Exception {
// init client.
String password = wTableOptions.getPassword();
int bid = wTableOptions.getBid();
String nc = wTableOptions.getNc();
wtableClient = WtableClient.getInstance(new WtableProperty(nc, bid, password));
//在创建表的时候可以约定,写入周期.也可以设置默认值
int flushInterval = wTableOptions.getFlushInterval();
//创建周期型线程池。
this.scheduler =
Executors.newScheduledThreadPool(1, new ExecutorThreadFactory("wtable-sink"));
//周期性线程池的使用方法可以参考网络
this.scheduledFuture =
this.scheduler.scheduleWithFixedDelay(
() -> {
synchronized (WTableSinkFunction.this) {
if (!closed) {
try {
flush();
} catch (Exception e) {
flushException = e;
}
}
}
},
flushInterval,
flushInterval,
TimeUnit.SECONDS);
}
@Override
public void close() throws Exception {
if (!closed) {
closed = true;
if (this.scheduledFuture != null) {
scheduledFuture.cancel(false);
this.scheduler.shutdown();
}
if (reduceBuffer.size() > 0) {
try {
flush();
} catch (Exception e) {
throw new RuntimeException("Writing records to wtable failed.", e);
}
}
}
wtableClient.close();
super.close();
}
/**
* 数据处理阶段。每来一条数据,都会执行这个方法。如果数据库不考虑批量写入。可以将写入的方法用在此处
*/
@Override
public void invoke(RowData value, Context context) throws Exception {
String key = value.getString(pkIndex).toString();
boolean flag = changeFlag(value.getRowKind());
//将数据保存到缓冲区。积累一定量后批量写入
synchronized (this) {
if (flag) {
byte[] jsonValue = valueEncoder.serialize(value);
SetArg setArg =
new SetArg(
tableId,
key.getBytes(StandardCharsets.UTF_8),
colKey,
jsonValue,
0,
ttl);
reduceBuffer.put(key, Tuple2.of(flag, setArg));
} else {
reduceBuffer.put(
key,
Tuple2.of(
flag,
new DelArg(tableId, key.getBytes(StandardCharsets.UTF_8), colKey)));
}
}
}
/**
* 写入逻辑。根据数据流的类型分为set数据集与del数据集
* 由于设定了批量写入的最大条数。按照设定的batchSize,进行分组。分开几次进行插入
* By the way:这里的逻辑处理只能处理按照指定的周期插入。无法满足,条数和周期数任意一中达成了就执行插入.
* 如果想实现任意一条满足插入,可以使用google的RateLimiter来实现
*/
private synchronized void flush() {
Collection<List<SetArg>> setArgs;
Collection<List<DelArg>> delArgs;
synchronized (this) {
counter.set(0);
setArgs =
reduceBuffer.entrySet().stream()
.filter(v -> v.getValue().f0)
.map(v -> (SetArg) v.getValue().f1)
.collect(
Collectors.groupingBy(
it -> counter.getAndIncrement() / batchSize))
.values();
counter.set(0);
delArgs =
reduceBuffer.entrySet().stream()
.filter(v -> !v.getValue().f0)
.map(v -> (DelArg) v.getValue().f1)
.collect(
Collectors.groupingBy(
it -> counter.getAndIncrement() / batchSize))
.values();
reduceBuffer.clear();
}
if (setArgs != null && !setArgs.isEmpty()) {
setArgs.forEach(
params -> {
try {
wtableClient.mSetEx(params);
} catch (WtableException e) {
throw new RuntimeException(e);
}
});
}
if (delArgs != null && !delArgs.isEmpty()) {
delArgs.forEach(
params -> {
try {
wtableClient.mDel(params);
} catch (WtableException e) {
throw new RuntimeException(e);
}
});
}
}
/**
* wtable连接器对于insert与delete有各自的方法。这里判断数据需要走那种类型
*/
private boolean changeFlag(RowKind rowKind) {
switch (rowKind) {
case INSERT:
case UPDATE_AFTER:
return true;
case DELETE:
case UPDATE_BEFORE:
return false;
default:
throw new UnsupportedOperationException(
String.format(
"Unknown row kind, the supported row kinds is: INSERT, UPDATE_BEFORE, UPDATE_AFTER,"
+ " DELETE, but get: %s.",
rowKind));
}
}
private void checkFlushException() {
if (flushException != null) {
throw new RuntimeException("Writing records to wtable failed.", flushException);
}
}
}
本段的主要难点为如何高性能的插入数据。这个和数据库的种类有关。