对于Flink的 DataStream API 的使用无非是下面的流程:

graph LR a[执行环境] b[数据源] c[转换] d[输出] a-->b b-->c c-->d

执行环境 Execution Environment

获取执行环境最简单最常见的方法就是:

import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment()

这种方式可以直接 根据当前运行的上下文得到正确的执行环境 ,若程序是独立运行的则返回一个local执行环境,如果是通过提交jar包到集群上执行的方式,则返回集群的执行环境。

这种方法的自然是 createLocalEnvironment 创建本地执行环境和 createRemoteEnvironment 创建集群执行环境两种方法结合实现的。

顾名思义 StreamExecutionEnvironment 是用来创建流处理的执行环境的,如果想要批处理的执行环境难道还要更换为创建批处理执行环境而重新修改代码吗?

显然不需要,Flink以流批一体著名,使用DataStream API就可以支持不同的 执行模式 ,默认情况下是使用流处理的模式,如果要更改为批处理的模式可以通过 命令行 来进行更改:

bin/flink run -Dexecution.runtime-mode=BATCH ...

同样也可以在代码上进行更改(写死代码不推荐):

import org.apache.flink.api.common.RuntimeExecutionMode;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment();
environment.setRuntimeMode(RuntimeExecutionMode.Batch)

当然Flink还提供一种自动模式 AUTOMATIC ,在这种模式下可以根据程序输入源的有界还是无界来自动的选择模式。

数据源 Source

Flink中使用 addSource 来添加Source:

DataStream<String> stream = environment.addSource(...);

addSource方法中需要传入一个 **SourceFunction ** 的接口,这个接口需要我们来实现,addSource方法返回的则是一个 DataStreamSource 对象。

而DataStreamSource继承自 SingleOutputStreamOperator ,SingleOutputStreamOperator又继承自 DataStream ,显而易见这是一个基于DataStream的操作。

回到addSource,SourceFunction需要实现的方法有两个:

// 运行循环读取数据 
void run(SourceFunction.SourceContext<T> var1) throws Exception;
// 取消作业
void cancel();

在Flink中一般并不需要我们来使用addSource实现方法来添加数据源,Flink已经准备好了许多情况下的获取数据源的方法,在 StreamExecutionEnvironment 中就能看到。

Kafka数据源

对于真正的数据流,实际中是Flink结合 分布式消息队列Kafka

<!--连接器依赖-->
<dependency>
  <groupId>org.apache.flink</groupId>
  <artifactId>flink-connector-kafka_2.12</artifactId>
  <version>1.13.0</version>
</dependency>
# 首先进入kafka目录中(高版本的kafka是有自带zookeeper的,我是用的版本是 kafka_2.13-3.2.1)
# 启动zookeeper服务
bin/zookeeper-server-start.sh config/zookeeper.properties

# 启动kafka
bin/kafka-server-start.sh config/server.properties

# 创建一个topic(这里创建一个名为flink-topic的topic)
bin/kafka-topics.sh --create --topic flink-topic --bootstrap-server localhost:9092


# 在创建的topic基础上启动一个producer
bin/kafka-console-producer.sh --topic flink-topic --bootstrap-server localhost:9092

# 使用 ctrl-c 结束
// Flink接收kafka数据
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.connectors.kafka.FlinkKafkaConsumer;

import java.util.Properties;

public class EventSource {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment environment = StreamExecutionEnvironment.getExecutionEnvironment();
        // 使用kafka作为数据源
        Properties properties = new Properties();
        properties.setProperty("bootstrap.servers","机器IP:9092");
        DataStreamSource<String> sourceKafka = environment.addSource(
                new FlinkKafkaConsumer<String>(
                        "flink-topic",
                        new SimpleStringSchema(),
                        properties)
        );

        sourceKafka.print();

        environment.execute();
    }
}


转换 Transform

转换操作可以说是整个流程最为核心最为丰富的一个步骤。

映射 Map

将数据流中的数据进行转换从而形成新的数据流。

举个例子,若有一个 自定义类Event 中有 字符串类型属性user,并有对应的 getset方法 ,我们需要使用map将其中的user提取出来,最常规的方法是 直接调用DataStreamSource中的map方法 ,而map方法中需要传入一个 MapFunction 的接口,这个接口需要实现一个map方法:

SingleOutputStreamOperator<String> output = source.map(new MapFunction<Event, String>() {
            @Override
            public String map(Event event) throws Exception {
                return event.getUser();
            }
        });

这种做法显然不够简洁,在只有一个实现方法的情况下我们可以使用lambda表达式:

SingleOutputStreamOperator<String> output = source.map(data -> data.getUser());

或者是使用方法引用:

SingleOutputStreamOperator<String> output = source.map(Event::getUser);

过滤 Filter

过滤操作非常易懂,就是将符合条件的信息筛选出来。

filter操作需要传入 FilterFunction 的接口,接口需要实现filter的方法。

同map操作一样,filter同样可以使用lambda来简化代码,下面我们 过滤出用户"小A" 的信息:

SingleOutputStreamOperator<Event> filterOutput = source.filter(data -> data.getUser().equals("小A"));

扁平映射 flatMap

扁平映射实际是两个操作的结合:flatten 和 map 。

将一个元素拆分可以得到多个元素,对多个元素进行映射,就是flatMap。

用法依旧是调用flatMap方法,传入 FlatMapFunction 接口,最后实现方法即可。

需要注意的是 FlatMapFunction 没有返回值,而且实现方法不再是单一的参数,而是如下两个参数:

@Override
public void flatMap(Object o, Collector collector) throws Exception {}

第二个参数是 收集器 Collector ,其中有一个 collect 方法,将收集的记录传递到下个任务。

如果我们灵活使用collect,它甚至可以达到过滤的效果:

SingleOutputStreamOperator<String> flatMapOutput = source.flatMap(new FlatMapFunction<Event, String>() {
            @Override
            public void flatMap(Event event, Collector<String> collector) throws Exception {
                if (event.getUser().equals("小A")) {
                    collector.collect(event.toString());
                }
            }
        });

当然仍然能使用lambda进行简化:

SingleOutputStreamOperator<String> flatMapOutput = source.flatMap((FlatMapFunction<Event, String>) (event, collector) -> {
            if (event.getUser().equals("小A")) {
                collector.collect(event.toString());
            }
        }).returns(new TypeHint<String>() {});
// 注意使用lambda之后会将Collector的泛型擦除,所以需要使用returns

简单聚合

在上面提及到的都是基本的算子,基于当前的数据进行转换,当然还有用于聚合运算的算子。

做简单的聚合运算的第一步就是对数据进行分区,keyBy 能很好的根据字段对数据分区,keyBy的传参需要实现 KeySelector 接口的 getKey 方法,根据 getKey 的返回值进行分组:

KeyedStream<Event, String> keyByUser = source.keyBy(new KeySelector<Event, String>() {
            @Override
            public String getKey(Event event) throws Exception {
                return event.getUser();
            }
        });

当然可以使用lambda简化:

KeyedStream<Event, String> keyByUser = source.keyBy((KeySelector<Event, String>) Event::getUser);

之后就能使用如sum、count、max、min等的一些聚合函数。

归约聚合

上面的简单聚合的作用功能比较局限,对于复杂的要求心有余而力不足。

要符合实际生产环境复杂的要求,就需要用到 reduce 方法。

reduce 的传参是一个 ReduceFunction 接口,需要实现它的reduce抽象方法:

T reduce(T var1, T var2) throws Exception;

其中val1表示以及被归约的的数据,val2表示下一个要被归约的数据。

下面示例一下统计user的event:

SingleOutputStreamOperator<Tuple2<String, Integer>> countEvent = source
                .map((MapFunction<Event, Tuple2<String, Integer>>) event -> Tuple2.of(event.getUser(), 1)) // 返回二元组<String,Integer>
                .returns(new TypeHint<Tuple2<String, Integer>>() {})  // 防止类型丢失
                .keyBy(data -> data.f0)  // 按照二元组的第一个元素分组
                .reduce((ReduceFunction<Tuple2<String, Integer>>) (t1, t2) -> Tuple2.of(t1.f0, t1.f1 + t2.f1))  // count
                .returns(new TypeHint<Tuple2<String, Integer>>() {});  // 防止类型丢失

物理分区 Physical Partitioning

在前面有keyBy函数对于流进行分区,这种分区是把数据按key来区分,分的是否合理均匀、每个key的数据具体到哪一个分区是我们无法干涉的,通常我们也将keyBy这种分区称为是一种 逻辑分区 logical partitioning

为了防止 数据倾斜 ,有时候我们需要手动的控制数据分区策略,也就是将数据 物理分区 ,实现真正的分开。

Flink默认的是 轮询分区 的方式,即按照并行度平均的分配到各个分区的策略:

source.rebalance();

我们还可以使用 shuffle 操作让数据服从均匀分布:

source.shuffle();

还有一种 rescale 重缩放分区 ,底层也是轮询,但区别是并不是对下游所有分区进行发放,而是将区域分为和上游对应的组,小组内进行轮询发放:

source.rescale();

在一些特定的需求下需要 broadcast 广播 的方式,将数据发放到下游所有的分区:

source.broadcast();

还有 global 全局分区 将所有数据发放到一个分区:

source.global();


输出 Sink

流的输出最简单的就是直接在控制台打印,但是这种方式在实际中并没什么用,通常我们需要将其连接到外部系统进行输出,

要知道Flink是 有状态的 流处理系统,如果输出由用户自己来实现的话,很难保证出故障的时候像其它步骤一样还有CheckPoint的保障,所以Flink将Sink这一步骤也统一了,只需要我们使用 addSink 就能实现输出到外部系统的操作。

addSink需要传入 SinkFunction 接口,接口需要实现 invoke 方法。

本地文件作为Sink

// 输出到本地文件
        StreamingFileSink<String> fileSink = StreamingFileSink.<String>forRowFormat(
                        new Path("./sinkOut"),  // 输出路径
                        new SimpleStringEncoder<>("UTF-8")  // 编码
                )
                .withRollingPolicy(
                        DefaultRollingPolicy.builder()
                                .withMaxPartSize(1024 * 1024 * 1024)  // 文件最大大小
                                .withRolloverInterval(TimeUnit.MINUTES.toMillis(10))  // 时间段10分钟
                                .withInactivityInterval(TimeUnit.MINUTES.toMillis(2))  // 等待时间2分钟
                                .build()
                )
                .build();

        source.map(data -> data.toString())
                .addSink(fileSink); // 输出

Kafka最为Sink

// 使用kafka作为Sink
        source.map(data -> data.toString())
                .addSink(new FlinkKafkaProducer<String>(
                        "机器IP:9092",  // brokerList
                        "string-topic",  //topic
                        new SimpleStringSchema()
                ));

JDBC作为Sink

这里以mysql为例,先引入依赖:

<!--jdbc-connector-->
        <dependency>
            <groupId>org.apache.flink</groupId>
            <artifactId>flink-connector-jdbc_${scala.binary.version}</artifactId>
            <version>${flink.version}</version>
        </dependency>
        <!--mysql-connector-->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
        </dependency>

在数据库建立对应的表:

CREATE TABLE Event (
	`user` varchar(100) NULL,
	url varchar(100) NULL,
	`timestamp` TIMESTAMP NULL,
	coin DOUBLE NULL
)
ENGINE=InnoDB
DEFAULT CHARSET=utf8
COLLATE=utf8_general_ci;

编写sink:

source.addSink(JdbcSink.sink(
                "INSERT INTO event(user,url,timestamp,coin) VALUES(?,?,?,?)", // SQL语句
                ((preparedStatement, event) -> { // 对应字段
                    preparedStatement.setString(1,event.getUser());
                    preparedStatement.setString(2,event.getUrl());
                    preparedStatement.setTimestamp(3,event.getTimestamp());
                    preparedStatement.setDouble(4,event.getCoin());
                }),
                new JdbcConnectionOptions.JdbcConnectionOptionsBuilder() // JDBC属性配置
                        .withUrl("jdbc:mysql://localhost:3306/DB_01")
                        .withDriverName("com.mysql.cj.jdbc.Driver")
                        .withUsername("username")
                        .withPassword("password")
                        .build()
        ));

在大数据中使用关系数据库作为sink输出端的场景并不多,更多的是选择redis这种kv数据库或者是效率更高的其它非关系型数据库。

如果flink并没有为我们实现相应的连接器,我们就要使用用户自定义的Sink输出了。