一个 Flink 程序,其实就是对 DataStream 的各种转换。具体来说说由5部分构成
⚫ 获取执行环境(execution environment)
⚫ 读取数据源(source)
⚫ 定义基于数据的转换操作(transformations) 
⚫ 定义计算结果的输出位置(sink) 
⚫ 触发程序执行(execute)

执行环境(Execution Environment)

我们在提交作业执行计算时,首先必须获取当前 Flink 的运行环境,从而建立起与 Flink 框架之间的联系。只有获取了环境上下文信息,才能将具体的任务调度到不同的 TaskManager 执行。

创建执行环境:

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

源算子(Source)

Flink 可以从各种来源获取数据,然后构建 DataStream 进行转换处理。一般将数据的输入来源称为数据源(data source),而读取数据的算子就是源算子(source operator)。所以,source
就是我们整个处理程序的输入端。

添加 source 的方式:

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

方法传入一个对象参数,需要实现 SourceFunction 接口;返回 DataStreamSource。

从 Kafka 读取数据

public class SourceKafkaTest {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        Properties properties = new Properties();
        properties.setProperty("bootstrap.servers", "hadoop102:9092");
        properties.setProperty("group.id", "consumer-group");
        properties.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        properties.setProperty("auto.offset.reset", "latest");
        DataStreamSource<String> stream = env.addSource(new
                FlinkKafkaConsumer<String>(
                "clicks",
                new SimpleStringSchema(),
                properties
        ));
        stream.print("Kafka");
        env.execute();
    } 
}

自定义 Source

自定义的数据源,需要实现 SourceFunction 接口。主要重写两个关键方法:run()和 cancel()。
⚫ run()方法:使用运行时上下文对象(SourceContext)向下游发送数据;
⚫ cancel()方法:通过标识位控制退出循环,来达到中断数据源的效果。

public class ClickSource implements SourceFunction<Event> {
    // 声明一个布尔变量,作为控制数据生成的标识位
    private Boolean running = true;
    @Override
    public void run(SourceContext<Event> ctx) throws Exception {
        Random random = new Random(); // 在指定的数据集中随机选取数据
        String[] users = {"Mary", "Alice", "Bob", "Cary"};
        String[] urls = {"./home", "./cart", "./fav", "./prod?id=1", "./prod?id=2"};
        while (running) {
            ctx.collect(new Event(
                    users[random.nextInt(users.length)],
                    urls[random.nextInt(urls.length)],
                    Calendar.getInstance().getTimeInMillis()
            ));
            // 隔 1 秒生成一个点击事件,方便观测
            Thread.sleep(1000);
        }
    }
    @Override
    public void cancel() {
        running = false;
    }
}

转换算子(Transformation)

基本转换算子

调用.map()方法,实现MapFunction()接口,常用lambda表达式

调用.filter()方法,实现FilterFunction()接口,常用lambda表达式,与map相似

调用.flatMap()方法,实现FlatMapFunction()接口,使用collector.collect()方法想下游发送数据。

source.flatMap((FlatMapFunction<String,String>)
        //拷贝小括号,写死右箭头,落地大括号
        (value,out)-> {
            if (value.equals("white")){
                out.collect(value);
            }else if (value.equals("black")){
                out.collect(value);
                out.collect(value);
            }else {
                for (int i = 0; i < 4; i++) {
                    out.collect(value);
                }
            }
        }).returns(Types.STRING);

注意:由于java有泛型擦除,对于特殊返回值需要用returns方法指定返回值类型

聚合算子(Aggregation)

1. 按键分区(keyBy)
对于 Flink 而言,DataStream 是没有直接进行聚合的 API 的。因为我们对海量数据做聚合肯定要进行分区并行处理,这样才能提高效率。所以在 Flink 中,要做聚合,需要先进行分区;这个操作就是通过 keyBy 来完成的。keyBy 通过指定键(key),可以将一条流从逻辑上划分成不同的任务槽。通过计算 key 的哈希值(hash code),对分区数进行取模运算来实现的。

keyBy 得到的结果将不再是 DataStream,而是会将 DataStream 转换为KeyedStream。KeyedStream 可以认为是“分区流”或者“键控流”,它是对 DataStream 按照key 的一个逻辑分区。
2. 简单聚合
有了按键分区的数据流 KeyedStream,我们就可以基于它进行聚合操作了。Flink 为我们
内置实现了一些最基本、最简单的聚合 API,主要有以下几种:
⚫ sum():在输入流上,对指定的字段做叠加求和的操作。
⚫ min():在输入流上,对指定的字段求最小值。
⚫ max():在输入流上,对指定的字段求最大值。
⚫ minBy():与 min()类似,在输入流上针对指定字段求最小值。不同的是,min()只计算指定字段的最小值,其他字段会保留最初第一个数据的值;而 minBy()则会返回包含字段最小值的整条数据。
⚫ maxBy():与 max()类似,在输入流上针对指定字段求最大值。两者区别与min()/minBy()完全一致。

一个聚合算子,会为每一个key保存一个聚合的值,在Flink中我们把它叫作“状态”(state)。
所以每当有一个新的数据输入,算子就会更新保存的聚合结果,并发送一个带有更新后聚合值
的事件到下游算子。对于无界流来说,这些状态是永远不会被清除的,所以我们使用聚合算子,
应该只用在含有有限个 key 的数据流上。

3. 归约聚合(reduce)

reduce 的语义是针对列表进行规约操作,运算规则由 ReduceFunction 中的 reduce方法来定义,而在 ReduceFunction 内部会维护一个初始值为空的累加器,注意累加器的类型和输入元素的类型相同,当第一条元素到来时,累加器的值更新为第一条元素的值,当新的元素到来时,新元素会和累加器进行累加操作,这里的累加操作就是 reduce 函数定义的运算规则。然后将更新以后的累加器的值向下游输出。

val resultResult = inputstream
      .keyBy(_.sensor_id)
      .reduce(new ReduceFunction[SensorReading] {
        override def reduce(t: SensorReading, t1: SensorReading): SensorReading = {
          new SensorReading(t.sensor_id,t.timestamp,t.temperature + t1.temperature)
        }
      })

用户自定义函数(UDF)

// 自定义 MapFunction 的实现类
public static class MyTuple2Mapper implements MapFunction<Event, Tuple2<String,Long>>{
    @Override
    public Tuple2<String, Long> map(Event value) throws Exception {
        return Tuple2.of(value.user, 1L);
    }
}

富函数类(Rich Function Classes)

“富函数类”也是 DataStream API 提供的一个函数类的接口,所有的 Flink 函数类都有其Rich 版本。既然“富”,那么它一定会比常规的函数类提供更多、更丰富的功能。与常规函数类的不同主要在于,富函数类可以获取运行环境的上下文,并拥有一些生命周期方法,所以可以实现更复杂的功能(连接数据库、)。

⚫ open()方法,是 Rich Function 的初始化方法,也就是会开启一个算子的生命周期。当一个算子的实际工作方法例如 map()或者 filter()方法被调用之前,open()会首先被调用。所以像文件 IO 的创建,数据库连接的创建,配置文件的读取等等这样一次性的工作,都适合在 open()方法中完成
⚫ close()方法,是生命周期中的最后一个调用的方法,类似于解构方法。一般用来做一些清理工作。

// 将点击事件转换成长整型的时间戳输出
clicks.map(new RichMapFunction<Event, Long>() {
    @Override
    public void open(Configuration parameters) throws Exception {
        super.open(parameters);
        System.out.println(" 索 引 为 " + getRuntimeContext().getIndexOfThisSubtask() + " 的任务开始");
    }
    @Override
    public Long map(Event value) throws Exception {
        return value.timestamp;
    }
    @Override
    public void close() throws Exception {
        super.close();
        System.out.println(" 索 引 为 " + getRuntimeContext().getIndexOfThisSubtask() + " 的任务结束");
    }
}).print();

物理分区(Physical Partitioning)

对于keyBy,它就是一种按照键的哈希值来进行重新分区的操作。只不过这种分区操作只能保证把数据按key“分开”,至于分得均不均匀、每个 key 的数据具体会分到哪一区去,这些是完全无从控制的——所以我们有时也说,keyBy 是一种逻辑分区(logical partitioning)操作。

物理分区(physical partitioning)。也就是我们要真正控制分区策略,精准地调配数据,告诉每个数据到底去哪里。物理分区与 keyBy 另一大区别在于,keyBy 之后得到的是一KeyedStream,而物理分区之后结果仍是 DataStream,且流中元素数据类型保持不变。从这一点也可以看出,分区算子并不对数据进行转换处理,只是定义了数据的传输方式。

1. 随机分区(shuffle)
最简单的重分区方式就是直接“洗牌”。通过调用 DataStream 的.shuffle()方法,将数据随
机地分配到下游算子的并行任务中去。

flink 拿到stream 变table_数据

 

2. 轮询分区(Round-Robin)
轮询也是一种常见的重分区方式。简单来说就是“发牌”,按照先后顺序将数据做依次分发。通过调用 DataStream 的.rebalance()方法,就可以实现轮询重分区。可以将输入流数据平均分配到下游的并行任务中去。

flink 拿到stream 变table_数据_02

 3. 重缩放分区(rescale)
重缩放分区和轮询分区非常相似。当调用 rescale()方法时,其实底层也是使用 Round-Robin算法进行轮询,但是只会将数据轮询发送到下游并行任务的一部分中。也就是说,“发牌人”如果有多个,那么 rebalance 的方式是每个发牌人都面向所有人发牌;而 rescale的做法是分成小团体,发牌人只给自己团体内的所有人轮流发牌。

flink 拿到stream 变table_数据_03

 由于 rebalance 是所有分区数据的“重新平衡”,当 TaskManager 数据量较多时,这种跨节
点的网络传输必然影响效率;而如果我们配置的 task slot 数量合适,用 rescale 的方式进行“局
部重缩放”,就可以让数据只在当前 TaskManager 的多个 slot 之间重新分配,从而避免了网络
传输带来的损耗。

4.广播(broadcast)
这种方式其实不应该叫做“重分区”,因为经过广播之后,数据会在不同的分区都保留一份,可能进行重复处理。可以通过调用 DataStream 的 broadcast()方法,将输入数据复制并发送到下游算子的所有并行任务中去。
5. 全局分区(global)
全局分区也是一种特殊的分区方式。这种做法非常极端,通过调用.global()方法,会将所有的输入流数据都发送到下游算子的第一个并行子任务中去。这就相当于强行让下游任务并行度变成了 1,所以使用这个操作需要非常谨慎,可能对程序造成很大的压力。

6.自定义分区(Custom)

通过使用partitionCustom()方法来自定义分区策略。在调用时,方法需要传入两个参数,第一个是自定义分区器(Partitioner)对象,第二个是应用分区器的字段,它的指定方式与keyBy指定key基本一样:可以通过字段名称指定,可以通过字段位置索引来指定,还可以实现一个KeySelector。

// 将自然数按照奇偶分区
stream.partitionCustom(new Partitioner<Integer>() {
    @Override
    public int partition(Integer key, int numPartitions) {
        return key % 2;
    }
}, new KeySelector<Integer, Integer>() {
    @Override
    public Integer getKey(Integer value) throws Exception {
        return value;
    }
}).print().setParallelism(2);

输出算子(Sink)

Sink 在 Flink 中代表了将结果数据收集起来、输出到外部的意思,所以我们这里统
一把它直观地叫作“输出算子”。

stream.addSink(new SinkFunction(…));


输出到 Kafka


stream.addSink(new FlinkKafkaProducer<String>(
                "clicks",
                new SimpleStringSchema(),
                properties
        ));

自定义 Sink 输出(输出Hbase)

stream.addSink(new RichSinkFunction<String>() {
        public org.apache.hadoop.conf.Configuration configuration; // 管理 Hbase 的配置信息,这里因为 Configuration 的重名问题,将类以完整路径
        public Connection connection; // 管理 Hbase 连接
        @Override
        public void open(Configuration parameters) throws Exception {
            super.open(parameters);
            configuration = HBaseConfiguration.create();
            configuration.set("hbase.zookeeper.quorum",
                    "hadoop102:2181");
            connection =
                    ConnectionFactory.createConnection(configuration);
        }
        
        @Override
        public void invoke(String value, Context context) throws Exception {
            Table table = connection.getTable(TableName.valueOf("test")); // 表名为 test
            Put put = new Put("rowkey".getBytes(StandardCharsets.UTF_8)); // 指定 rowkey
            put.addColumn("info".getBytes(StandardCharsets.UTF_8) // 指定列名
                    , value.getBytes(StandardCharsets.UTF_8) // 写入的数据
                    , "1".getBytes(StandardCharsets.UTF_8)); // 写入的数据
            table.put(put); // 执行 put 操作
            table.close(); // 将表关闭
        }
    
        @Override
        public void close() throws Exception {
            super.close();
            connection.close(); // 关闭连接
        }
    });

触发程序执行

当写完输出(sink)操作并不代表程序已经结束。因为当 main()方法被调用时,其实只是定义了作业的每个执行操作,然后添加到数据流图中;这时并没有真正处理数据——因为数据可能还没来。Flink 是由事件驱动的,只有等到数据到来,才会触发真正的计算,这也被称为“延迟执行”或“懒执行”(lazy execution)。
所以我们需要显式地调用执行环境的 execute()方法,来触发程序执行。execute()方法将一
直等待作业完成,然后返回一个执行结果(JobExecutionResult)。

env.execute();