Flink的DataStream API的使用


文章目录

  • ***Flink的DataStream API的使用***
  • 一、Flink的DataStream API的使用------执行环境(Execution Environment)
  • 二、Flink的DataStream API的使用------源算子(Source)
  • 三、Flink的DataStream API的使用------转换算子(Transformation)
  • 1、基本转换算子
  • 1.1、映射(map)
  • 1.2、过滤(filter)
  • 1.3、扁平映射(flatMap)
  • 2、聚合算子(Aggregation)
  • 2.1、按键分区(keyBy)
  • 2.2、简单聚合
  • 2.3、归约聚合(reduce)
  • 3、用户自定义函数(UDF)
  • 3.1、函数类(Function Classes)
  • 3.2、匿名函数(Lambda)
  • 3.3、富函数类(Rich Function Classes)
  • 4、物理分区(Physical Partitioning)
  • 4.1、随机分区(shuffle)
  • 4.2、轮询分区(Round-Robin)
  • 4.3、重缩放分区(rescale)
  • 4.4、自定义分区(Custom)
  • 四、Flink的DataStream API的使用------输出算子(Sink)



一、Flink的DataStream API的使用------执行环境(Execution Environment)

执行环境这一节请点击超链接阅读

二、Flink的DataStream API的使用------源算子(Source)

源算子这一节请点击超链接阅读

三、Flink的DataStream API的使用------转换算子(Transformation)

flink rowdata 转换 flink转换算子_flink


数据源读入数据之后,我们就可以使用各种转换算子,将一个或多个 DataStream 转换为

新的 DataStream,一个 Flink 程序的核心,其实就是所有的转换操作,它们决定了处理的业务逻辑。

我们可以针对一条流进行转换处理,也可以进行分流、合流等多流转换操作,从而组合成

复杂的数据流拓扑。

1、基本转换算子

1.1、映射(map)

map算子主要用于将数据流中的数据进行转换,形成新的数据流。简单来说,就是一个“一一映射”,消费一个元素就产出一个元素。

不同方式实现从Event中提取user:

flink rowdata 转换 flink转换算子_java_02

public class TransFormMapTest {
    public static void main(String[] args) throws Exception{
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        //从元素中读取数据
        DataStreamSource<Event> stream = env.fromElements(
                new Event("Mary", "./home", 1000L),
                new Event("Bob", "./cart", 2000L),
                new Event("Alice", "./home", 3000L)
        );
        //进行转换计算,提取user
        //1.使用自定义类,实现MapFunction接口
        SingleOutputStreamOperator<String> result = stream.map(new MyMapper());

        //2.使用匿名类实现MapFunction接口
        SingleOutputStreamOperator<String> result2 = stream.map(new MapFunction<Event, String>() {
            @Override
            public String map(Event value) throws Exception {
                return value.user;
            }
        });

        //3.传入Lambda表达式
        SingleOutputStreamOperator<String> result3 = stream.map(data -> data.user);
        
        result.print("1");
        result2.print("2");
        result3.print("3");

        env.execute();

    }

    //自定义MapFunction
    public static class MyMapper implements MapFunction<Event,String>{
        @Override
        public String map(Event value) throws Exception {
            return value.user;
        }
    }
}
1.2、过滤(filter)

filter 转换操作,顾名思义是对数据流执行一个过滤,通过一个布尔条件表达式设置过滤条件,对于每一个流内元素进行判断,若为 true 则元素正常输出,若为 false 则元素被过滤掉。

flink rowdata 转换 flink转换算子_flink_03

1.3、扁平映射(flatMap)

flatMap 操作又称为扁平映射,主要是将数据流中的整体(一般是集合类型)拆分成一个

一个的个体使用。消费一个元素,可以产生 0 到多个元素。flatMap 可以认为是“扁平化”(flatten)和“映射”(map)两步操作的结合,也就是先按照某种规则对数据进行打散拆分,再对拆分后的元素做转换处理。

flink rowdata 转换 flink转换算子_flink_04


同 map 一样,flatMap 也可以使用 Lambda 表达式或者 FlatMapFunction 接口实现类的方式来进行传参,返回值类型取决于所传参数的具体逻辑,可以与原数据流相同,也可以不同。

flatMap 操作会应用在每一个输入事件上面,FlatMapFunction 接口中定义了 flatMap 方法,用户可以重写这个方法,在这个方法中对输入数据进行处理,并决定是返回 0 个、1 个或多个结果数据。因此 flatMap 并没有直接定义返回值类型,而是通过一个“收集器”(Collector)来指定输出。希望输出结果时,只要调用收集器的.collect()方法就可以了;这个方法可以多次调用,也可以不调用。所以 flatMap 方法也可以实现 map 方法和 filter 方法的功能,当返回结果是 0 个的时候,就相当于对数据进行了过滤,当返回结果是 1 个的时候,相当于对数据进行了简单的转换操作。

举例:

public class TransformFlatMapTest {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        //从元素中读取数据
        DataStreamSource<Event> stream = env.fromElements(
                new Event("Mary", "./home", 1000L),
                new Event("Bob", "./cart", 2000L),
                new Event("Alice", "./home", 3000L)
        );

        //1.实现FlatMapFunction
        stream.flatMap(new MyFlatMap()).print("1");

        //2.传入一个Lambda表达式
        stream.flatMap((Event value, Collector<String> out) -> {
            if (value.user.equals("Mary"))
            out.collect(value.url);
            else if (value.user.equals("Bob")){
                out.collect(value.user);
                out.collect(value.url);
                out.collect(value.timestamp.toString());
            }
        }).returns(new TypeHint<String>() {
        }).print("2");

        env.execute();

    }

    //实现一个自定义的FlatMapFunction
    public static class MyFlatMap implements FlatMapFunction<Event, String>{
        @Override
        public void flatMap(Event value, Collector<String> out) throws Exception {
            out.collect(value.user);
            out.collect(value.url);
            out.collect(value.timestamp.toString());
        }
    }
}

2、聚合算子(Aggregation)

2.1、按键分区(keyBy)

对于 Flink 而言,DataStream 是没有直接进行聚合的 API 的。因为我们对海量数据做聚合肯定要进行分区并行处理,这样才能提高效率。所以在 Flink 中,要做聚合,需要先进行分区;这个操作就是通过 keyBy 来完成的。

keyBy 是聚合前必须要用到的一个算子。keyBy 通过指定键(key),可以将一条流从逻辑上划分成不同的分区(partitions)。这里所说的分区,其实就是并行处理的子任务,也就对应着任务槽(task slot)。

基于不同的 key,流中的数据将被分配到不同的分区中去,这样一来,所有具有相同的 key 的数据,都将被发往同一个分区,那么下一步算子操作就将会在同一个slot中进行处理了。

flink rowdata 转换 flink转换算子_flink rowdata 转换_05


在内部,是通过计算 key 的哈希值(hash code),对分区数进行取模运算来实现的。所以这里 key 如果是 POJO 的话,必须要重写 hashCode()方法。

keyBy()方法需要传入一个参数,这个参数指定了一个或一组 key。有很多不同的方法来指定 key:比如对于 Tuple 数据类型,可以指定字段的位置或者多个位置的组合;对于 POJO 类型,可以指定字段的名称(String);另外,还可以传入 Lambda 表达式或者实现一个键选择器(KeySelector),用于说明从数据中提取 key 的逻辑。

2.2、简单聚合

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

public class TransFormSimpleAggTest {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        //从元素中读取数据
        DataStreamSource<Event> stream = env.fromElements(
                new Event("Mary", "./home", 1000L),
                new Event("Bob", "./cart", 2000L),
                new Event("Alice", "./home", 3000L),
                new Event("Bob", "./cart", 3500L),
                new Event("Alice", "./home", 3600L)
        );

        //按键分组之后进行聚合,提取当前用户最后一次访问数据
        stream.keyBy(new KeySelector<Event, String>() {
            @Override
            public String getKey(Event value) throws Exception {
                return value.user;
            }
        }).max("timestamp").print("max:");

        stream.keyBy(data -> data.user).maxBy("timestamp").print("maxBy:");

        env.execute();
    }
}

简单聚合算子返回的,同样是一个SingleOutputStreamOperator,也就是从 keyedStream 又转换成了常规的 DataStream。所以可以这样理解:keyBy 和聚合是成对出现的,先分区、后聚合,得到的依然是一个 DataStream。而且经过简单聚合之后的数据流,元素的数据类型保持不变。

2.3、归约聚合(reduce)

如果说简单聚合是对一些特定统计需求的实现,那么reduce算子就是一个一般化的聚合统计操作了。从大名鼎鼎的MapReduce开始,我们对reduce操作就不陌生:它可以对已有的数据进行归约处理,把每一个新输入的数据和当前已经归约出来的值,再做一个聚合计算。与简单聚合类似,reduce 操作也会将KeyedStream转换DataStream。它不会改变流的元素数据类型,所以输出类型和输入类型是一样的。调用 KeyedStream 的 reduce 方法时,需要传入一个参数,实现 ReduceFunction 接口。
接口在源码中的定义如下:

public interface ReduceFunction<T> extends Function, Serializable {
    T reduce(T var1, T var2) throws Exception;
}

我们可以单独定义一个函数类实现 ReduceFunction 接口,也可以直接传入一个匿名类。
当然,同样也可以通过传入 Lambda 表达式实现类似的功能。
与简单聚合类似,reduce 操作也会将 KeyedStream 转换为 DataStrema。它不会改变流的元素数据类型,所以输出类型和输入类型是一样的。
测试代码:

public class TransformReduceTest {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        //从元素中读取数据
        DataStreamSource<Event> stream = env.fromElements(
                new Event("Mary", "./home", 1000L),
                new Event("Bob", "./cart", 2000L),
                new Event("Alice", "./home", 3000L),
                new Event("Bob", "./cart", 3500L),
                new Event("Alice", "./home", 3600L)
        );

        //1.统计每个用户的访问频次
        SingleOutputStreamOperator<Tuple2<String, Long>> clicksByUser = stream.map(new MapFunction<Event, Tuple2<String, Long>>() {
            @Override
            public Tuple2<String, Long> map(Event value) throws Exception {
                return Tuple2.of(value.user, 1L);
            }
        }).keyBy(data -> data.f0).reduce(new ReduceFunction<Tuple2<String, Long>>() {
            @Override
            public Tuple2<String, Long> reduce(Tuple2<String, Long> value1, Tuple2<String, Long> value2) throws Exception {
                return Tuple2.of(value1.f0, value1.f1 + value2.f1);
            }
        });

        //2.选取当前最活跃的用户
        SingleOutputStreamOperator<Tuple2<String, Long>> result = clicksByUser.keyBy(data -> "key").reduce(new ReduceFunction<Tuple2<String, Long>>() {
            @Override
            public Tuple2<String, Long> reduce(Tuple2<String, Long> value1, Tuple2<String, Long> value2) throws Exception {
                return value1.f1 > value2.f1 ? value1 : value2;
            }
        });

        result.print();
        env.execute();


    }
}

3、用户自定义函数(UDF)

Flink 的DataStream API编程风格其实是一致的:基本上都是基于DataStream调用一个方法,表示要做一个转换操作;方法需要传入一个参数,这个参数都是需要实现一个接口。

3.1、函数类(Function Classes)

对于大部分操作而言,都需要传入一个用户自定义函数UDF,实现相关操作的接口,来完成处理逻辑的定义。Flink 暴露了所有 UDF 函数的接口,具体实现方式为接口或者抽象类,例如 MapFunction、FilterFunction、ReduceFunction 等。
所以最简单直接的方式,就是自定义一个函数类,实现对应的接口。

3.2、匿名函数(Lambda)

匿名函数(Lambda 表达式)是Java 8引入的新特性,方便我们更加快速清晰地写代码。Lambda 表达式允许以简洁的方式实现函数,以及将函数作为参数来进行传递,而不必声明额外的(匿名)类。
Flink 的所有算子都可以使用 Lambda 表达式的方式来进行编码,但是,当 Lambda 表
达式使用 Java 的泛型时,我们需要显式的声明类型信息。

3.3、富函数类(Rich Function Classes)

富函数类也是DataStream API提供的一个函数类的接口,所有Flink函数类都有其Rich版本。富函数类一般是以抽象类的形式出现的。如RichMapFunction、RichFilterFunction、RichReduceFunction 等。
既然“富”,那么它一定会比常规的函数类提供更多、更丰富的功能。与常规函数类的不同主要在于,富函数类可以获取运行环境的上下文,并拥有一些生命周期方法,所以可以实现更复杂的功能。
注:生命周期的概念在编程中其实非常重要,到处都有体现。例如:对于 C 语言来说,
我们需要手动管理内存的分配和回收,也就是手动管理内存的生命周期。分配内存而不回收,会造成内存泄漏,回收没有分配过的内存,会造成空指针异常。而在 JVM 中,虚拟机会自动帮助我们管理对象的生命周期。对于前端来说,一个页面也会有生命周期。数据库连接、网络连接以及文件描述符的创建和关闭,也都形成了生命周期。所以生命周期的概念在编程中是无处不在的,需要我们多加注意。
Rich Function 有生命周期的概念。典型的生命周期方法有:
⚫ open()方法,是 Rich Function 的初始化方法,也就是会开启一个算子的生命周期。当一个算子的实际工作方法例如 map()或者 filter()方法被调用之前,open()会首先被调
用。所以像文件 IO 的创建,数据库连接的创建,配置文件的读取等等这样一次性的
工作,都适合在 open()方法中完成。
⚫ close()方法,是生命周期中的最后一个调用的方法,类似于解构方法。一般用来做一
些清理工作。
需要注意的是,这里的生命周期方法,对于一个并行子任务来说只会调用一次;而对应的,实际工作方法,例如 RichMapFunction 中的 map(),在每条数据到来后都会触发一次调用。
测试代码:

public class TransformRichFunctionTest {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        //从元素中读取数据
        DataStreamSource<Event> stream = env.fromElements(
                new Event("Mary", "./home", 1000L),
                new Event("Bob", "./cart12", 2000L),
                new Event("Alice", "./home123", 3000L)
        );

        stream.map(new MyRichMapper()).setParallelism(2).print();

        env.execute();
    }

    //实现一个自定义类的富函数类
    public static class MyRichMapper extends RichMapFunction<Event, Integer>{
        @Override
        public void open(Configuration parameters) throws Exception {
            super.open(parameters);
            System.out.println("open生命周期被调用" + getRuntimeContext().getIndexOfThisSubtask() + "号任务启动");
        }

        @Override
        public Integer map(Event value) throws Exception {
            return value.url.length();
        }

        @Override
        public void close() throws Exception {
            super.close();
            System.out.println("close生命周期被调用" + getRuntimeContext().getIndexOfThisSubtask() + "号任务结束");
        }
    }
}

4、物理分区(Physical Partitioning)

如果说 keyBy 这种逻辑分区是一种“软分区”,那真正硬核的分区就应该是所谓的“物理分区”(physical partitioning)。也就是我们要真正控制分区策略,精准地调配数据,告诉每个数据到底去哪里。其实这种分区方式在一些情况下已经在发生了:例如我们编写的程序可能对多个处理任务设置了不同的并行度,那么当数据执行的上下游任务并行度变化时,数据就不应该还在当前分区以直通(forward)方式传输了——因为如果并行度变小,当前分区可能没有下游任务了;而如果并行度变大,所有数据还在原先的分区处理就会导致资源的浪费。所以这种情况下,系统会自动地将数据均匀地发往下游所有的并行任务,保证各个分区的负载均衡。
有些时候,我们还需要手动控制数据分区分配策略。比如当发生数据倾斜的时候,系统无法自动调整,这时就需要我们重新进行负载均衡,将数据流较为平均地发送到下游任务操作分区中去。
Flink 对于经过转换操作之后的 DataStream,提供了一系列的底层操作接口,能够帮我们实现数据流的手动重分区。为了同 keyBy 相区别,我们把这些操作统称为“物理分区”操作。物理分区与 keyBy 另一大区别在于,keyBy 之后得到的是一个 KeyedStream,而物理分区之后结果仍是 DataStream,且流中元素数据类型保持不变。从这一点也可以看出,分区算子并不对数据进行转换处理,只是定义了数据的传输方式。

4.1、随机分区(shuffle)

最简单的重分区方式就是直接“洗牌”。通过调用 DataStream 的.shuffle()方法,将数据随

机地分配到下游算子的并行任务中去。

随机分区服从均匀分布(uniform distribution),所以可以把流中的数据随机打乱,均匀地传递到下游任务分区,因为是完全随机的,所以对于同样的输入数据, 每次执行得到的结果也不会相同。

flink rowdata 转换 flink转换算子_flink rowdata 转换_06


经过随机分区之后,得到的依然是一个 DataStream。

4.2、轮询分区(Round-Robin)

轮询也是一种常见的重分区方式。简单来说就是“发牌”,按照先后顺序将数据做依次分

发,通过调用DataStream的rebalance()方法,就可以实现轮询重分区。rebalance使用的是Round-Robin负载均衡算法,可以将输入流数据平均分配到下游的并行任务中去。

注:Round-Robin 算法用在了很多地方,例如 Kafka 和 Nginx。

flink rowdata 转换 flink转换算子_flink_07

4.3、重缩放分区(rescale)

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

的做法是分成小团体,发牌人只给自己团体内的所有人轮流发牌。

flink rowdata 转换 flink转换算子_java_08

4.4、自定义分区(Custom)

当Flink提供的所有分区策略都不能满足用户的需求时,我们可以通过使用partitionCustom()方法来自定义分区策略。
在调用时,方法需要传入两个参数,第一个是自定义分区器(Partitioner)对象,第二个是应用分区器的字段,它的指定方式与 keyBy 指定 key 基本一样:可以通过字段名称指定,也可以通过字段位置索引来指定,还可以实现一个 KeySelector。
测试代码:

public class TransformPatitionTest {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        //从元素中读取数据
        DataStreamSource<Event> stream = env.fromElements(
                new Event("Mary", "./home", 1000L),
                new Event("Bob", "./cart", 2000L),
                new Event("Alice", "./home", 3000L),
                new Event("Bob", "./cart", 3500L),
                new Event("Alice", "./home", 3600L)
        );

        //1.随机分区
//        stream.shuffle().print("shuffle").setParallelism(4);

        //2.轮询分区
//        stream.rebalance().print("rebalance").setParallelism(4);

        //3.rescale重缩放分区
//        env.addSource(new RichParallelSourceFunction<Integer>() {
//            @Override
//            public void run(SourceContext<Integer> ctx) throws Exception {
//                for(int i=1; i<9; i++){
//                    //将奇偶数分别发送到0号和1号并行分区
//                    if(i%2 == getRuntimeContext().getIndexOfThisSubtask())
//                        ctx.collect(i);
//                }
//            }
//
//            @Override
//            public void cancel() {
//
//            }
//        }).setParallelism(2).rescale().print().setParallelism(4);

        //4.广播
//        stream.broadcast().print("broadcast").setParallelism(4);

        //5.全局分区,谨慎使用
//        stream.global().print().setParallelism(4);

        //6.自定义重分区
        env.fromElements(1,2,3,4,5,6,7,8).partitionCustom(new Partitioner<Integer>() {
            @Override
            public int partition(Integer key, int i) {
                return key % 2;
            }
        }, new KeySelector<Integer, Integer>() {


            @Override
            public Integer getKey(Integer value) throws Exception {
                return value;
            }
        }).print().setParallelism(4);

        env.execute();
    }
}

四、Flink的DataStream API的使用------输出算子(Sink)