Flink 官网主页地址:https://flink.apache.org Flink 官方中文地址:https://nightlies.apache.org/flink/flink-docs-stable/zh/
DataStream API DataStream API 是 Flink 核心层 API。一个 Flink 程序,其实就是对 DataStream 的各种转换。具体来说,代码基本上都由以下几部分构成:
1、执行环境(Execution Environment) Flink 程序可以在各种上下文环境中运行;我们可以在本地 JVM 中执行程序,也可以提交到远程集群上运行。
不同的环境,代码提交运行的过程会有所不同。这就要求我们在提交作业执行计算时,首先必须获取当前 Flink 的运行环境,从而建立起与 Flink 框架之间的联系。
1.1、创建执行环境 我们要获取的执行环境,是 StreamExecutionEnviroment
类的对象,这是所有 Flink 程序的基础。在代码中成绩执行环境的方式,就是调用这个类的静态方法,具体有一下三种。
getExecutionEnviroment
最简单的方式,就是直接调用 getExecutionEnvironment
方法。它会根据当前运行的上下文直接得到正确的结果:如果程序是独立运行的,就返回一个本地执行环境;如果是创建了 jar 包,然后从命令行调用它并提交到集群执行,那么就返回集群的执行环境。也就是说,这个方法会根据当前运行的方式,自行决定该返回什么样的运行环境。
1 StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
这种方式,用起来简单高效,是最常用的一种创建执行环境的方式。
createLocalEnviroment
这个方法返回一个本地执行环境。可以在调用时传入一个参数,指定默认的并行度;如果不传入,则没人并行度就是本地的 CPU 核心数。
1 StreamExecutionEnvironment localEnv = StreamExecutionEnvironment.createLocalEnvironment();
createRemoteEnvironment
这个方法返回集群执行环境。需要在调用时指定 JobManager 的主机名和端口号,并指定要在集群中运行的Jar包。
1 2 3 4 5 6 StreamExecutionEnvironment remoteEnv = StreamExecutionEnvironment .createRemoteEnvironment( "host" , 1234 , "path/to/jarFile.jar" );
在获取到程序执行环境后,我们还可以对执行环境进行灵活的设置。比如可以全局设置程序的并行度、禁用算子链,还可以定义程序的时间语义、配置容错机制。
1.2、执行模式(Execution Mode) 从 Flink1.12 开始,官方推荐的做法是直接使用 DataStream API,在提交任务时通过将执行模式设为 BATCH 来进行批处理。不建议使用 DataSet API。
1 2 StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
DataStream API 执行模式包括:流执行模式、批执行模式和自动模式
1.3、触发程序执行 需要注意的是,写完输出(Sink)操作并不代表程序已经结束。因为当 main()
方法被调用时,其实只是定义了作业的每个执行操作如何添加到数据流图中;这时候并没有真正处理数据。因为数据可能还没有来。
Flink 是由事件驱动的,只有等到数据到来,才会触发真正的计算,这也被称为“延迟执行”或“懒执行”。所以我们需要显示地调用执行环境的 execute()
方法来触发程序的执行。execute()
方法讲一直等待作业完成,返回一个执行结果(JobExecutionResult)。
2、源算子(Source) Fink 可以从各种来源获取数据,如何构建 DataStream 进行转换处理。一般将数据地输入来源称为数据源(data source),而读取数据的算子就是源算子。所以,source 就是我们整个处理程序的输入端。
在 Flink1.12 以前,旧的添加 source 的方法是调用执行环境的 addSource()
方法:
1 DataStream<String> stream = env.addSource(...);
方法传入的参数是一个“源函数”(source function),需要实现 SourceFunction 接口。
从 Flink1.12 开始,主要使用流批一体的新 Source 架构:
1 DataStreamSource<String> stream = env.fromSource(…)
Flink 直接提供了很多预实现的接口,此外还有很多外部连接工具也帮我们实现了对应的 Source,通常情况下足以应对我们的实际需求。
2.1、准备工作 为了方便练习,我们创建 WaterSensor 类作为数据模型。
字段名
数据类型
说明
id
String
水位传感器类型
ts
Long
传感器记录时间戳
vc
Integer
水位记录
具体代码如下:
1 2 3 4 5 6 7 8 @Data @NoArgsConstructor @AllArgsConstructor public class WaterSensor { private String id; private Long ts; private Integer vc; }
这里需要注意的点:
类是公共的
所有属性都是公有的
所有属性的类型都是可以序列化的
Flink 会把这样的类作为一种特殊的 POJO(Plain Ordinary Java Object 简单的 Java 对象,实际就是普通 JavaBeans)数据类型来对待,方便数据的解析和序列化。
我们这里自定义的 POJO 类会在后面的代码中频繁使用,所以在后面的代码中碰到,把这里的 POJO 类导入就好了。
2.2、从集合中读取数据 最简单的读取数据的方式,就是在代码中直接创建一个 Java 集合,然后调用执行环境的 fromCollection 方法进行读取。这相当于将数据临时存储到内存中,形成特殊的数据结构后,作为数据源使用,一般用于测试。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class Demo01_CollectionSource { public static void main (String[] args) throws Exception { Configuration conf = new Configuration(); conf.setInteger("rest.port" , 3333 ); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(conf); env.fromCollection(Arrays.asList(1 ,2 ,3 ,4 ,5 ,6 )).print(); env.fromElements(7 ,8 ,9 ,10 ,11 ,12 ).printToErr(); env.execute(); } }
2.3、从文件中读取数据 真正的实际应用中,自然不会直接将数据写在代码中。通常情况下,我们会从存储介质中获取数据,一个比较常见的方式就是读取日志文件。这也是批处理中最常见的读取方式。 读取文件,需要添加文件连接器依赖:
1 2 3 4 5 <dependency > <groupId > org.apache.flink</groupId > <artifactId > flink-connector-files</artifactId > <version > ${flink.version}</version > </dependency >
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 public class Demo02_FileSource { public static void main (String[] args) throws Exception { Configuration configuration = new Configuration(); configuration.setInteger("rest.port" ,3333 ); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration); FileSource<String> source = FileSource.forRecordStreamFormat(new TextLineInputFormat(), new Path("data/ws.json" )).build(); DataStreamSource<String> streamSource = env.fromSource(source, WatermarkStrategy.noWatermarks(), "myfile" ); streamSource.print(); env.execute(); } }
说明:
参数可以是目录,也可以是文件;还可以从 HDFS 目录下读取,使用路径 hdfs://…
路径可以是相对路径,也可以是绝对路径
相对路径是从系统属性 user.dir
获取路径;idea 下是 project 的根目录,standalone 模式下是集群节点根目录。
2.4、从 Soceket 读取数据 不论从集合还是文件,我们读取的其实都是有界数据。在流处理的场景中,数据往往是无界的。
我们之前用到的读取 socket 文本流,就是流处理场景。但是这种方式由于吞吐量小、稳定性较差,一般也是用于测试。
1 DataStream<String> stream = env.socketTextStream("hadoop102" , 9999 );
2.5、从 Kafka 读取数据 Flink 官方提供了连接工具 flink-connector-kafka,帮我们实现了一个消费者 FlinkKafkaConsumer,它就是用来读取 Kafka 数据的 SourceFunction。
所以想要以 Kafka 作为数据源获取数据,我们只需要引入 Kafka 连接器的依赖。Flink 官方提供的是一个通用的 Kafka 连接器,它会自动跟踪最新版本的 Kafka 客户端。目前最新版本只支持 0.10.0 版本以上的 Kafka。这里我们需要导入的依赖如下。
1 2 3 4 5 <dependency > <groupId > org.apache.flink</groupId > <artifactId > flink-connector-kafka</artifactId > <version > ${flink.version}</version > </dependency >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 public class Demo03_KafkaSource { public static void main (String[] args) throws Exception { Configuration configuration = new Configuration(); configuration.setInteger("rest.port" ,3333 ); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration); KafkaSource<String> kafkaSource = KafkaSource.<String>builder() .setBootstrapServers("hadoop102:9092" ) .setTopics("FlinkTest" ) .setGroupId("flink" ) .setValueOnlyDeserializer(new SimpleStringSchema()) .setProperty(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true" ) .setProperty(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000" ) .build(); env.fromSource(kafkaSource, WatermarkStrategy.noWatermarks(),"kafka" ).print(); env.execute(); } }
2.6、从数据生成器读取数据 Flink 从 1.11 开始提供了一个内置的 DataGen 连接器,主要是用于生成一些随机数,用于在没有数据源的时候,进行流任务的测试以及性能测试等。1.17 提供了新的Source写法,需要导入依赖:
1 2 3 4 5 <dependency > <groupId > org.apache.flink</groupId > <artifactId > flink-connector-datagen</artifactId > <version > ${flink.version}</version > </dependency >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 public class Demo04_DataGenSource { public static void main (String[] args) throws Exception { Configuration configuration = new Configuration(); configuration.setInteger("rest.port" ,3333 ); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration); String[] ids={"s1" ,"s2" ,"s3" }; DataGeneratorSource<WaterSensor> source = new DataGeneratorSource<WaterSensor>( new GeneratorFunction<Long,WaterSensor>() { @Override public WaterSensor map (Long aLong) throws Exception { return new WaterSensor( ids[RandomUtils.nextInt(0 ,ids.length)], System.currentTimeMillis(), RandomUtils.nextInt(100 ,30000 ) ); } }, Long.MAX_VALUE, RateLimiterStrategy.perSecond(5 d), TypeInformation.of(WaterSensor.class ) ) ; DataStreamSource<WaterSensor> sensorDataStreamSource = env.fromSource(source, WatermarkStrategy.noWatermarks(), "dg" ); sensorDataStreamSource.print(); env.execute(); } }
2.7、Flink 支持的数据类型 1、Flink 的类型系统
Flink 使用”类型信息“(TypeInformation)来统一表示数据类型。TypeInformation 类是 Flink 中所有类型描述符的基类。它涵盖了类型的一些基本属性,并为每个数据类型生成特定的序列化器、反序列化器和比较器。
2、Flink 支持的数据类型
对于常见的 Java 和 Scala 数据类型,Flink 都是支持的。Flink 在内部,Flink 对支持不同的类型进行了划分,这些类型可以在Types工具类中找到:
基本类型。所有 Java 基本类型及其包装类,再加上 Void、String、Date、BigDecimal 和 BigInteger。
数组类型。包括基本类型数组(Primitive_Array)和对象数组(Object_Array)。
复合数据类型。
Java 元组类型(Tuple):这是 Flink 内置的元组类型,是 Java API 的一部分。最多 25 个字段,也就是从Tuple0~Tuple25,不支持空字段。
Scala 样例类及 Scala 元组;不支持空字段。
行类型(ROW):可以认为是具有任意个字段的元组,并支持空字段。
POJO:Flink 自定义的类似于 Java Bean 模式的类。
辅助类型。Option、Either、List、Map 等
泛型类型(Generic)。Flink 支持所有的 Java 类和 Scala 类。不过如果没有按照上面 POJO 类型的要求来定义,就会被 Flink 当作泛型类来处理。Flink 会把泛型类型当作黑盒,无法获取它们内部的属性;它们也不是由 Flink 本身序列化的,而是由 Kryo 序列化的。
在这些类型中,元组类型和 POJO 类型最为灵活,因为它们支持创建复杂类型。而相比之下,POJO 还支持在键(key)的定义中直接使用字段名,这会让我们的代码可读性大大增加。所以,在项目实践中,往往会将流处理程序中的元素类型定为 Flink 的 POJO 类型。
Flink 对 POJO 类型的要求如下:
类是公有(public)的
有一个无参的构造方法
所有属性都是公有(public)的
所有属性的类型都是可以序列化的
3、类型提示(Type Hints)
Flink 还具有一个类型提取系统,可以分析函数的输入和返回类型,自动获取类型信息,从而获得对应的序列化器和反序列化器。但是,由于 Java 中泛型擦除的存在,在某些特殊情况下(比如 Lambda 表达式中),自动提前的信息是不够精细的,只告诉 Flink 当前的元素由“船头、船身、船尾”构成,根本无法重建出“大船”的模样;这时就需要显式地提供类型信息,才能使应用程序正常工作或提高其性能。
为了解决这类问题,Java API提供了专门的“类型同时”(type hints)
回忆一下之前的 word count 流处理程序,我们在将 String 类型的每个词转换成(word,count)二元组后,就明确地用 returns 指定了返回的类型。因为对于 map 里传入了 Lambda 表达式,系统只能推断出返回的是Tuple2 类型,而无法得到 Tuple<String,Long>。只有显示地告诉系统当前的返回类型,才能正确地解析出完整数据。
1 2 .map(word -> Tuple2.of(word, 1L )) .returns(Types.TUPLE(Types.STRING, Types.LONG));
Flink 还专门提供了 TypeHint 类,它可以捕获泛型的类型信息,并且一直记录下来,为运行时通过足够的信息。我们同样可以通过 returns() 方法,明确地指定转换之后的 DataStream 里元素的类型。
1 returns(new TypeHint<Tuple2<Integer, SomeType>>(){})
数据源读入数据之后,我们就可以使用各种转换算子,将一个或多个 DataStream 转换为新的 DataStream。
3.1、基本转换算子(map/filter/flatMap) 3.1.1、映射(map) map 是大家非常熟悉的大数据操作算子,主要用于将数据流中的数据进行转换,形成新的数据流。简单来说,就是一个“一一映射”,消费一个元素就产出一个元素。
我们只需要基于 DataStream 调用 map()
方法就可以进行转换处理。方法需要传入的参数是接口 MapFunction
的实现;返回值类型还是 DataStream,不过泛型(流中的元素类型)可能改变。
下面是模拟读取数据库数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 public class Demo01_Map { public static void main (String[] args) throws Exception { Configuration configuration = new Configuration(); configuration.setInteger("rest.port" ,3333 ); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration); env.setParallelism(1 ); List<Integer> nums = Arrays.asList(1 , 2 , 3 , 4 , 5 ); env.fromCollection(nums) .map(new MyMapFunction()) .print(); env.execute(); } private static class MyMapFunction extends RichMapFunction <Integer , String > { private String conn; @Override public String map (Integer integer) throws Exception { System.out.println(integer + "使用" + conn); return integer.toString(); } @Override public void open (Configuration parameters) throws Exception { conn="连接" ; System.out.println("创建好了连接.................." ); } @Override public void close () throws Exception { System.out.println("关闭了连接..................." ); } } }
上面代码中,MapFuntion 实现类的泛型类型与输入数据类型和输出数据的类型有关。在实现 MapFunction 接口的时候,需要指定两个泛型,分别是输入事件和输出事件的类型,还需要重写一个 map()
方法,定义从一个输入事件转换为另一个输出事件的具体逻辑。
3.1.2、过滤(filter) filter 转换操作,顾名思义是对数据流执行一个过滤,通过一个布尔条件表达式设置过滤条件,对于每一个流内元素进行判断,若为 true 则元素正常输出,若为 false 则元素被过滤掉。
进行 filter 转换之后的新数据流的数据类型与原数据流是相同的。filter 转换需要传入的参数需要实现FilterFunction
接口,而 FilterFunction
内要实现 filter()
方法,就相当于一个返回布尔类型的条件表达式。
输出偶数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class Demo02_Filter { public static void main (String[] args) throws Exception { Configuration configuration = new Configuration(); configuration.setInteger("rest.port" ,3333 ); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration); List<Integer> nums = Arrays.asList(1 , 2 , 3 , 4 , 5 , 6 ); env.fromCollection(nums) .filter(integer -> integer%2 ==0 ) .print(); env.execute(); } }
3.1.3、扁平映射(flatMap) flatMap 操作又称为扁平映射,主要是将数据流中的整体(一般是集合类型)拆分成一个一个的个体使用。消费一个元素,可以产生 0 到多个元素。flatMap 可以认为是“扁平化”(flatten)和“映射”(map)两步操作的结合,也就是先按照某种规则对数据进行打散拆分,再对拆分后的元素做转换处理。
同 map 一样,flatMap 也可以使用 Lambda 表达式或者 FlatMapFunction
接口实现类的方式来进行传参,返回值类型取决于所传参数的具体逻辑,可以与原数据流相同,也可以不同。
输出偶数,并且输出多次
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public class Demo03_FlatMap { public static void main (String[] args) throws Exception { Configuration configuration = new Configuration(); configuration.setInteger("rest.port" ,3333 ); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration); List<Integer> nums = Arrays.asList(1 , 2 , 3 , 4 , 5 , 6 ); env.fromCollection(nums) .flatMap(new FlatMapFunction<Integer, Integer>() { @Override public void flatMap (Integer integer, Collector<Integer> collector) throws Exception { if (integer%2 ==0 ){ collector.collect(integer); collector.collect(integer); collector.collect(integer); collector.collect(integer); } } }) .print(); env.execute(); } }
3.2、聚合算子 计算的结果不仅依赖当前数据,还跟之前的数据有关,相当于要把所有数据聚在一起进行汇总合并—这就是所谓的“聚合”(Aggregation),类似于 MapReduce 中的 Reduce 操作。
3.2.1、按键分区(KeyBy),要做聚合,需要先进行分区 对于 Flink 而言,DataStream 是没有直接进行聚合的 API 的。因为我们对海量数据做聚合肯定要进行分区并行处理,这样才能提高效率。所以在 Flink 中,要做聚合,需要先进行分区;这个操作就是通过 KeyBy 来完成的。
KeyBy 是聚合前必须用到的一个算子。KeyBy 通过指定键(key),可以将一条流从逻辑上划分为不同的分区(partitions)。这里所说的分区,其实就是并行处理的子任务。
基于不同的 key,流中的数据将被分配到不同的分区中;这样一来,所有具有相同的 key 的数据,都将被发往同一个分区。
在内部,是通过计算 key 的哈希值(hash code),对分区进行取模运算来实现的,所以这里 key 如果是 POJO 的话,必须重写 hashCode 方法。
keyBy()
方法需要传入一个参数,这个参数指定了一个或一组 key。有很多不同的方法来指定 key; 比如 Tuple 数据类型,可以指定字段的位置或多个位置的组合; 对于 POJO 类型,可以指定字段的名称(String); 另外,还可以传入 Lambda 表达式或者实现一个键选择器(KeySelector),用于说明从数据中提取 key 的逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class Demo01_CommonAgg { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); DataStreamSource<WaterSensor> stream = env.fromElements( new WaterSensor("sensor_1" , 1 , 1 ), new WaterSensor("sensor_1" , 2 , 2 ), new WaterSensor("sensor_2" , 2 , 2 ), new WaterSensor("sensor_3" , 3 , 3 ) ); KeyedStream<WaterSensor, String> keyedStream = stream.keyBy(e -> e.id); KeyedStream<WaterSensor, String> keyedStream1 = stream.keyBy(new KeySelector<WaterSensor, String>() { @Override public String getKey (WaterSensor e) throws Exception { return e.id; } }); env.execute(); } }
运行截图:
需要注意的是,keyBy 得到的结果将不再是 DataStream,而是会将 DataStream 转换为 KeyedStream。KeyedStream 可以认为是“分区流”或者“键控流”,它是对 DataStream 按照 key 的一个逻辑分区,所以泛型有两个类型:除去当前流中的元素类型外,还需要指定 key 的类型。
KeyedStream 也继承自 DataStream,所以基于它的操作也都归属于 DataStream API。但它跟之前的转换操作得到的 SingleOutputStreamOperator 不同,只是一个流的分区操作,并不是一个转换算子。KeyedStream 是一个非常重要的数据结构,只有基于它才可以做后续的聚合操作(比如 sum,reduce)。
3.2.2、简单聚合(Sum/Min/MinBy/MaxBy) 有了按键分区的数据流 KeyedStream,我们可以就可以给予它进行聚合操作了。Flink 为我们内置实现了一些最基本、最简单的聚合 API,主要有以下几种:
sum()
:在输入流上,对指定的字段做叠加求和的操作。
min()
:在输入流上,对指定的字段求最小值
max()
:在输入流上,对指定的字段求最大值
minBy()
:与 min()
类似,在输入流上针对指定字段求最小值。不同的是,min()
只计算指定字段的最小值,其他字段会保留最初第一个数据的值;而 minBy()
则会返回包含字段最小值的整条数据
maxBy()
:与 max()
类似,在输入流上针对指定字段求最大值。两者区别与 min()
和 MinBy()
的区别一样。
简单聚合算子使用很方便,语义也非常明确。这些聚合方法调用时,也需要传入参数;但并不像基本转换算子那样需要实现自定义函数,只要说明聚合指定字段就可以了。指定字段的方式有两种:指定位置和指定名称
对于元组类型的数据,可以使用这两种方式来指定字段。需要注意的是,元组中字段的名称是以 f0、f1、f2、… 来命名的。
如果数据流的类型是 POJO 类,那么就只能通过指定字段名称来指定,不能通过位置来指定了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class TransAggregation { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); DataStreamSource<WaterSensor> stream = env.fromElements( new WaterSensor("sensor_1" , 1 , 1 ), new WaterSensor("sensor_1" , 2 , 2 ), new WaterSensor("sensor_2" , 2 , 2 ), new WaterSensor("sensor_3" , 3 , 3 ) ); stream.keyBy(e -> e.id).max("vc" ); env.execute(); } }
简单聚合算子返回的同样是一个 SingleOutputStreamOperator,也就是从 KeyedStream 又转换成了常规的 DataStream。所以可以这样理解:keyBy 和聚合是成对出现的,先分区、后聚合,得到的依然是一个 DataStream。而且经过简单聚合之后的数据流,元素的数据类型保持不变。
一个聚合算子,会为每一个 key 保存一个聚合的值,在 Flink 中我们把它叫作“状态”(state)。所以每当有一个新的数据输入,算子就会更新保存的聚合结果,并发送一个带有更新聚合值的事件到下游算子。对于无界流来说,这些状态是永远不会被清除的,所以我们使用聚合算子,一个只用在含有有限个 key 的数据流上。
3.2.3、归约聚合(Reduce) Reduce 可以对已有的数据进行归约处理,把每一个新输入的数据和当前已经归约出来的值再做一个聚合计算。
Reduce 操作也会将 KeyedStream 转换为 DataStream。它不会改变流的元素数据类型,所以输出类型和输入类型是一样的。
调用 KeyedStream 的 Reduce 方法时,需要传入一个参数,实现 ReduceFunction 接口。接口在源码中的定义如下:
1 2 3 public interface ReduceFunction <T > extends Function , Serializable { T reduce (T value1, T value2) throws Exception ; }
ReduceFunction 接口里需要实现 Reduce()
方法,这个方法接受两个输入事件,经过转换处理之后输出一个相同类型的事件。在流处理的底层实现过程中,实际上是将中间“合并的结果”作为任务的一个状态保存起来;之后每来一个新的数据,就和之前的聚合状态进一步做归约。
我们可以单独定义一个函数类实现 ReduceFunction 接口,也可以直接传入一个匿名类。当然,同样也可以通过传入 Lambda 表达式实现类似的功能。
定义一个 WaterSensorMapFunction:
1 2 3 4 5 6 7 8 public class WaterSensorMapFunction implements MapFunction <String , WaterSensor > { @Override public WaterSensor map (String s) throws Exception { String[] fileds = s.split("," ); return new WaterSensor(fileds[0 ],Long.valueOf(fileds[1 ]),Integer.valueOf(fileds[2 ])); } }
案例:使用 Reduce 实现取最小值功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 public class Demo02_Reduce { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); DataStreamSource<String> ds = env.socketTextStream("hadoop102" , 9999 ); env.setParallelism(1 ); SingleOutputStreamOperator<WaterSensor> map = ds.map(new WaterSensorMapFunction()); KeyedStream<WaterSensor, String> keyBy = map.keyBy(WaterSensor::getId); keyBy.reduce(new ReduceFunction<WaterSensor>() { @Override public WaterSensor reduce (WaterSensor value1, WaterSensor value2) throws Exception { if (value1.getVc()<value2.getVc()){ return value1; }else { return value2; } } }) .print(); env.execute(); } }
Reduce 和简单聚合算子一样,也要针对每一个 key 保存状态。因为状态不会清空,所以我们需要将 Reduce 算子作用在一个有限 key 的流上。
3.3、用户自定义函数(user-defined function,UDF) 用户自定义函数(user-defined function,UDF),即用户可以根据自身需求,重新实现算子的逻辑。
用户自定义函数分为:函数类、匿名函数、富函数类。
3.3.1 函数类(Function Classes) Flink 暴露了所有 UDF 函数的接口,具体实现方式为接口或者抽象类,例如 MapFunction、FilterFunction、ReduceFunction 等。所以用户可以自定义一个函数类,实现对应的接口。
需求:用来从用户的点击数据中筛选包含“sensor_1”的内容:
方式一:实现 FilterFunction 接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class TransFunctionUDF { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); DataStreamSource<WaterSensor> stream = env.fromElements( new WaterSensor("sensor_1" , 1 , 1 ), new WaterSensor("sensor_1" , 2 , 2 ), new WaterSensor("sensor_2" , 2 , 2 ), new WaterSensor("sensor_3" , 3 , 3 ) ); DataStream<String> filter = stream.filter(new UserFilter()); filter.print(); env.execute(); } public static class UserFilter implements FilterFunction <WaterSensor > { @Override public boolean filter (WaterSensor e) throws Exception { return e.id.equals("sensor_1" ); } } }
方式二:通过匿名类来实现 FilterFunction 接口:
1 2 3 4 5 6 DataStream<String> stream = stream.filter(new FilterFunction< WaterSensor>() { @Override public boolean filter (WaterSensor e) throws Exception { return e.id.equals("sensor_1" ); } });
方法二的优化:为了类可以更加通用,我们还可以将用于过滤的关键字 “sensor_1” 抽象出来作为类的属性,调用构造方法时传进去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 DataStreamSource<WaterSensor> stream = env.fromElements( new WaterSensor("sensor_1" , 1 , 1 ), new WaterSensor("sensor_1" , 2 , 2 ), new WaterSensor("sensor_2" , 2 , 2 ), new WaterSensor("sensor_3" , 3 , 3 ) ); DataStream<String> stream = stream.filter(new FilterFunctionImpl("sensor_1" ));public static class FilterFunctionImpl implements FilterFunction <WaterSensor > { private String id; FilterFunctionImpl(String id) { this .id=id; } @Override public boolean filter (WaterSensor value) throws Exception { return thid.id.equals(value.id); } }
方式三:采用匿名函数(Lambda)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public class TransFunctionUDF { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); DataStreamSource<WaterSensor> stream = env.fromElements( new WaterSensor("sensor_1" , 1 , 1 ), new WaterSensor("sensor_1" , 2 , 2 ), new WaterSensor("sensor_2" , 2 , 2 ), new WaterSensor("sensor_3" , 3 , 3 ) ); SingleOutputStreamOperator<String> filter = stream.filter(sensor -> "sensor_1" .equals(sensor.id)); filter.print(); env.execute(); } }
3.3.2 富函数类(Rich Function Classes) “富函数类”也是 DataStream API 提供的一个函数类的接口,所有的 Flink 函数类都有其 Rich 版本。富函数类一般是以抽象类的形式出现的。例如:RichMapFunction、RichFilterFunction、RichReduceFunction 等。
与常规函数类的不同主要在于,富函数类可以获取运行环境的上下文,并拥有一些生命周期方法,所以可以实现更复杂的功能。
Rich Function 有生命周期的概念。典型的生命周期方法有:
open()
方法:Rich Function 的初始化方法,也就是会开启一个算子的生命周期。当一个算子的实际工作方法例如 map()
或者 filter()
方法被调用之前,open()
会首先被调用。
close()
方法:生命周期中的最后一个调用的方法,类似于结束方法。一般用来做一些清理工作。
需要注意的是,这里的生命周期方法,对于一个并行子任务来说只会调用一次;而对应的,实际工作方法,例如RichMapFunction 中的 map,在每条数据来到后都会触发一次调用。
来看一个例子说明:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class RichFunctionExample { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(2 ); env .fromElements(1 ,2 ,3 ,4 ) .map(new RichMapFunction<Integer, Integer>() { @Override public void open (Configuration parameters) throws Exception { super .open(parameters); System.out.println("索引是:" + getRuntimeContext().getIndexOfThisSubtask() + " 的任务的生命周期开始" ); } @Override public Integer map (Integer integer) throws Exception { return integer + 1 ; } @Override public void close () throws Exception { super .close(); System.out.println("索引是:" + getRuntimeContext().getIndexOfThisSubtask() + " 的任务的生命周期结束" ); } }) .print(); env.execute(); } }
运行截图:
3.4、物理分区算子(Physical Partitioning) 常见的物理分区策略有:随机分配(Random)、轮询分配(Round-Robin)、重缩放(Rescale)和广播(Broadcast)
3.4.1、随机分区(Shuffle) 最简单的重分区方式就是直接“洗牌”。通过调用 DataStream 的 .shuffle()
方法,将数据随机地分配到下游算子的并行任务中去。
随机分区服从均匀分布(uniform distribution),所以可以把流中的数据随机打乱,均匀地传递到下游任务分区。因为是完全随机的,所以对于同样的输入数据, 每次执行得到的结果也不会相同。
经过随机分区之后,得到的依然是一个 DataStream。
我们可以做个简单测试:将数据读入之后直接打印到控制台,将输出的并行度设置为 2,中间经历一次 shuffle。执行多次,观察结果是否相同。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class ShuffleExample { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(2 ); DataStreamSource<String> stream = env.socketTextStream("hadoop102" , 9999 ); stream.shuffle().print(); env.execute(); } }
第一次运行截图: 第二次运行截图:
3.4.2、轮询分区(Round-Robin) 轮询,简单来说就是“发牌”,按照先后顺序将数据做依次分发。通过调用 DataStream 的 .rebalance()
方法,就可以实现轮询重分区。rebalance 使用的是 Round-Robin 负载均衡算法,可以将输入流数据平均分配到下游的并行任务中去。
3.4.3、重缩放分区(rescale) 重缩放分区和轮询分区非常相似。当调用 rescale()
方法时,其实底层也是使用 Round-Robin 算法进行轮询,但是只会将数据轮询发送到下游并行任务的一部分中。rescale 的做法是分成小团体,发牌人只给自己团体内的所有人轮流发牌。
3.4.4、广播(BroadCast) 这种方式其实不应该叫做“重分区”,因为经过广播之后,数据会在不同的分区都保留一份,可能进行重复处理。可以通过调用 DataStream的broadcast()
方法,将输入数据复制并发送到下游算子的所有并行任务中去。
3.4.5、全局分区(Global) 全局分区也是一种特殊的分区方式。这种做法非常极端,通过调用 .global()
方法,会将所有的输入流数据都发送到下游算子的第一个并行子任务中去(0号)。这就相当于强行让下游任务并行度变成了 1,所以使用这个操作需要非常谨慎,可能对程序造成很大的压力。
3.4.6、自定义分区(Custom) 当 Flink 提供的所有分区策略都不能满足用户的需求时,我们可以通过使用 partitionCustom()
方法来自定义分区策略。
1、自定义分区器
1 2 3 4 5 6 7 public class MyPartitioner implements Partitioner <String > { @Override public int partition (String key, int numPartitions) { return Integer.parseInt(key) % numPartitions; } }
2、使用自定义分区
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class PartitionCustomDemo { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.createLocalEnvironmentWithWebUI(new Configuration()); env.setParallelism(2 ); DataStreamSource<String> socketDS = env.socketTextStream("hadoop102" , 7777 ); DataStream<String> myDS = socketDS .partitionCustom( new MyPartitioner(), value -> value); myDS.print(); env.execute(); } }
3.5、分流 所谓“分流”,就是将一条数据流拆分成完全独立的两条、甚至多条流。也就是基于一个 DataStream,定义一些筛选条件,将符合条件的数据拣选出来放到对应的流里。
3.5.1、简单实现(filter) 其实根据条件筛选数据的需求,本身非常容易实现:只要针对同一条流多次独立调用 .filter()
方法进行筛选,就可以得到拆分之后的流了。
案例需求:读取一个整数数字流,将数据流划分为奇数流和偶数流。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 public class Demo01_FilterDivide { public static void main (String[] args) throws Exception { Configuration configuration = new Configuration(); configuration.setInteger("rest.port" ,8888 ); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration); env.setParallelism(2 ); env.disableOperatorChaining(); SingleOutputStreamOperator<Integer> map = env.socketTextStream("hadoop102" , 9999 ) .name("s" ) .map(Integer::valueOf); map.filter(s -> s % 2 == 1 ).print("奇数" ); map.filter(s -> s % 2 == 0 ).print("偶数" ); env.execute(); } }
这种实现非常简单,但代码显得有些冗余——我们的处理逻辑对拆分出的三条流其实是一样的,却重复写了三次。而且这段代码背后的含义,是将原始数据流 stream 复制三份,然后对每一份分别做筛选;这明显是不够高效的。我们自然想到,能不能不用复制流,直接用一个算子就把它们都拆分开呢?
3.5.2、使用测输出流 简单来说,只需要调用上下文 ctx 的 .output()
方法,就可以输出任意类型的数据了。而侧输出流的标记和提取,都离不开一个“输出标签”(OutputTag),指定了侧输出流的id和类型。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 public class SplitStreamByOutputTag { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); SingleOutputStreamOperator<WaterSensor> ds = env.socketTextStream("hadoop102" , 7777 ) .map(new WaterSensorMapFunction()); OutputTag<WaterSensor> s1 = new OutputTag<>("s1" , Types.POJO(WaterSensor.class )) {}; OutputTag<WaterSensor> s2 = new OutputTag<>("s2" , Types.POJO(WaterSensor.class )) {}; SingleOutputStreamOperator<WaterSensor> ds1 = ds.process(new ProcessFunction<WaterSensor, WaterSensor>() { @Override public void processElement (WaterSensor value, Context ctx, Collector<WaterSensor> out) throws Exception { if ("s1" .equals(value.getId())) { ctx.output(s1, value); } else if ("s2" .equals(value.getId())) { ctx.output(s2, value); } else { out.collect(value); } } }); ds1.print("主流,非s1,s2的传感器" ); SideOutputDataStream<WaterSensor> s1DS = ds1.getSideOutput(s1); SideOutputDataStream<WaterSensor> s2DS = ds1.getSideOutput(s2); s1DS.printToErr("s1" ); s2DS.printToErr("s2" ); env.execute(); } }
3.6、基本合流操作 在实际应用中,我们经常会遇到来源不同的多条流,需要将它们的数据进行联合处理。所以 Flink 中合流的操作会更加普遍,对应的 API 也更加丰富。
3.6.1、联合(union) 最简单的合流操作,就是直接将多条流合在一起,叫作流的“联合”(union)。联合操作要求必须流中的数据类型必须相同,合并之后的新流会包括所有流中的元素,数据类型不变。
在代码中,我们只要基于 DataStream 直接调用 .union()
方法,传入其他 DataStream 作为参数,就可以实现流的联合了;得到的依然是一个 DataStream:
1 stream1.union(stream2, stream3, ...)
注意:union()
的参数可以是多个 DataStream,所以联合操作可以实现多条流的合并。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 public class Demo01_Union { public static void main (String[] args) throws Exception { Configuration configuration = new Configuration(); configuration.setInteger("rest.port" ,8888 ); StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(configuration); env.setParallelism(1 ); DataStreamSource<Integer> ds = env.fromElements(1 , 2 , 3 , 4 , 5 ); DataStreamSource<Integer> ds1 = env.fromElements(11 , 12 , 13 , 14 , 15 ); DataStreamSource<Integer> ds2 = env.fromElements(21 , 22 , 23 , 24 , 25 ); DataStreamSource<String> ds3 = env.fromElements("1" ,"2" ,"3" ); DataStream<Integer> unionDS = ds.union(ds1, ds2); unionDS.print(); env.execute(); } }
运行截图:
3.6.2、连接(Connect) 流的联合虽然简单,不过受限于数据类型不能改变,灵活性大打折扣,所以实际应用较少出现。除了联合(union),Flink 还提供了另外一种方便的合流操作——连接(connect)。
1、连接流(Connect)
代码实现:需要分为两步:首先基于一条 DataStream 调用 .connect()
方法,传入另外一条DataStream 作为参数,将两条流连接起来,得到一个 ConnectedStreams;然后再调用同处理方法得到 DataStream。这里可以的调用的同处理方法有 .map()/.flatMap()
,以及 .process()
方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public class ConnectDemo { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1 ); SingleOutputStreamOperator<Integer> source1 = env .socketTextStream("hadoop102" , 9999 ) .map(i -> Integer.parseInt(i)); DataStreamSource<String> source2 = env.socketTextStream("hadoop102" , 9998 ); ConnectedStreams<Integer, String> connect = source1.connect(source2); SingleOutputStreamOperator<String> result = connect.map(new CoMapFunction<Integer, String, String>() { @Override public String map1 (Integer value) throws Exception { return "来源于数字流:" + value.toString(); } @Override public String map2 (String value) throws Exception { return "来源于字母流:" + value; } }); result.print(); env.execute(); } }
运行截图:
上面的代码中,ConnectedStreams 有两个类型参数,分别表示内部包含的两条流各自的数据类型;由于需要“一国两制”,因此调用 .map()
方法时传入的不再是一个简单的 MapFunction,而是一个 CoMapFunction,表示分别对两条流中的数据执行 map 操作。这个接口有三个类型参数,依次表示第一条流、第二条流,以及合并后的流中的数据类型。需要实现的方法也非常直白:.map1()
就是对第一条流中数据的 map 操作,.map2()
则是针对第二条流。
2、CoProcessFunction
与 CoMapFunction 类似,如果是调用 .map()
就需要传入一个 CoMapFunction,需要实现 map1()
、map2()
两个方法;而调用 .process()
时,传入的则是一个 CoProcessFunction。它也是“处理函数”家族中的一员,用法非常相似。它需要实现的就是 processElement1()
、processElement2()
两个方法,在每个数据到来时,会根据来源的流调用其中的一个方法进行处理。 值得一提的是,ConnectedStreams 也可以直接调用 .keyBy()
进行按键分区的操作,得到的还是一个 ConnectedStreams:
1 connectedStreams.keyBy(keySelector1, keySelector2);
这里传入两个参数 keySelector1 和 keySelector2,是两条流中各自的键选择器;当然也可以直接传入键的位置值(keyPosition),或者键的字段名(field),这与普通的 keyBy 用法完全一致。ConnectedStreams 进行 keyBy 操作,其实就是把两条流中 key 相同的数据放到了一起,然后针对来源的流再做各自处理,这在一些场景下非常有用。
案例需求:连接两条流,输出能根据 id 匹配上的数据(类似inner join效果)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 public class ConnectKeybyDemo { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(2 ); DataStreamSource<Tuple2<Integer, String>> source1 = env.fromElements( Tuple2.of(1 , "a1" ), Tuple2.of(1 , "a2" ), Tuple2.of(2 , "b" ), Tuple2.of(3 , "c" ) ); DataStreamSource<Tuple3<Integer, String, Integer>> source2 = env.fromElements( Tuple3.of(1 , "aa1" , 1 ), Tuple3.of(1 , "aa2" , 2 ), Tuple3.of(2 , "bb" , 1 ), Tuple3.of(3 , "cc" , 1 ) ); ConnectedStreams<Tuple2<Integer, String>, Tuple3<Integer, String, Integer>> connect = source1.connect(source2); ConnectedStreams<Tuple2<Integer, String>, Tuple3<Integer, String, Integer>> connectKey = connect.keyBy(s1 -> s1.f0, s2 -> s2.f0); SingleOutputStreamOperator<String> result = connectKey.process( new CoProcessFunction<Tuple2<Integer, String>, Tuple3<Integer, String, Integer>, String>() { Map<Integer, List<Tuple2<Integer, String>>> s1Cache = new HashMap<>(); Map<Integer, List<Tuple3<Integer, String, Integer>>> s2Cache = new HashMap<>(); @Override public void processElement1 (Tuple2<Integer, String> value, Context ctx, Collector<String> out) throws Exception { Integer id = value.f0; if (!s1Cache.containsKey(id)) { List<Tuple2<Integer, String>> s1Values = new ArrayList<>(); s1Values.add(value); s1Cache.put(id, s1Values); } else { s1Cache.get(id).add(value); } if (s2Cache.containsKey(id)) { for (Tuple3<Integer, String, Integer> s2Element : s2Cache.get(id)) { out.collect("s1:" + value + "<--------->s2:" + s2Element); } } } @Override public void processElement2 (Tuple3<Integer, String, Integer> value, Context ctx, Collector<String> out) throws Exception { Integer id = value.f0; if (!s2Cache.containsKey(id)) { List<Tuple3<Integer, String, Integer>> s2Values = new ArrayList<>(); s2Values.add(value); s2Cache.put(id, s2Values); } else { s2Cache.get(id).add(value); } if (s1Cache.containsKey(id)) { for (Tuple2<Integer, String> s1Element : s1Cache.get(id)) { out.collect("s1:" + s1Element + "<--------->s2:" + value); } } } }); result.print(); env.execute(); } }
运行截图:
4、输出算子(Sink) Flink 作为数据处理框架,最终还是要把计算处理的结果写入外部储存,为外部应用提供支持。
4.1、连接到外部系统 Flink 的 DataStream API 专门提供了向外部提供写入数据的方法:addSink。与 addSource 类似,addSink 方法对应着一个“Sink”算子,主要就是用来实现与外部系统连接、并将数据提交写入的。Flink 程序中所有对外的输出操作,一般都是利用 Sink 算子完成的。
Flink1.12 以前,Sink 算子的创建是通过调用 DataStream 的 .addSink()
方法实现的。
1 stream.addSink(new SinkFunction(…));
addSink 方法同样需要传入一个参数,实现的是 SinkFunction 接口。在这个接口中只需要重写一个方法 invoke(),用来将指定的值写入到外部系统中。这个方法在每条数据记录到来时都会调用。
Flink1.12 开始,同样重构了Sink架构,
当然,Sink 多数情况下同样并不需要我们自己实现。之前我们一直在使用的 print 方法其实就是一种 Sink,它表示将数据流写入标准控制台打印输出。Flink 官方为我们提供了一部分的框架的 Sink 连接器。如下图所示,列出了 Flink 官方目前支持的第三方系统连接器:
我们可以看到,像 Kafka 之类流式系统,Flink 提供了完美对接,source/sink 两端都能连接,可读可写;而对于 Elasticsearch、JDBC 等数据存储系统,则只提供了输出写入的 sink 连接器。
除 Flink 官方之外,Apache Bahir 框架,也实现了一些其他第三方系统与 Flink 的连接器。
除此以外,就需要用户自定义实现 sink 连接器了。
4.2、输出到文件 Flink 专门提供了一个流式文件系统的连接器:FileSink,为批处理和流处理提供了一个统一的 Sink,它可以将分区文件写入 Flink 支持的文件系统。
FileSink 支持行编码(Row-encoded)和批量编码(Bulk-encoded)格式。这两种不同的方式都有各自的构建器(builder),可以直接调用 FileSink 的静态方法:
行编码: FileSink.forRowFormat(basePath,rowEncoder)。
批量编码: FileSink.forBulkFormat(basePath,bulkWriterFactory)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 public class SinkFile { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(2 ); env.enableCheckpointing(2000 , CheckpointingMode.EXACTLY_ONCE); DataGeneratorSource<String> dataGeneratorSource = new DataGeneratorSource<>( new GeneratorFunction<Long, String>() { @Override public String map (Long value) throws Exception { return "Number:" + value; } }, Long.MAX_VALUE, RateLimiterStrategy.perSecond(1000 ), Types.STRING ); DataStreamSource<String> dataGen = env.fromSource(dataGeneratorSource, WatermarkStrategy.noWatermarks(), "data-generator" ); FileSink<String> fieSink = FileSink .<String>forRowFormat(new Path("f:/tmp" ), new SimpleStringEncoder<>("UTF-8" )) .withOutputFileConfig( OutputFileConfig.builder() .withPartPrefix("atguigu-" ) .withPartSuffix(".log" ) .build() ) .withBucketAssigner(new DateTimeBucketAssigner<>("yyyy-MM-dd HH" , ZoneId.systemDefault())) .withRollingPolicy( DefaultRollingPolicy.builder() .withRolloverInterval(Duration.ofMinutes(1 )) .withMaxPartSize(new MemorySize(1024 *1024 )) .build() ) .build(); dataGen.sinkTo(fieSink); env.execute(); } }
4.3、输出到 Kafka
添加 Kafka 连接器依赖
由于我们已经测试过从 Kafka 数据源读取数据,连接器相关依赖已经引入,这里就不重复介绍了。
启动 Kafka 集群
编写输出到 Kafka 的示例代码
输出无 key 的 record:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 public class SinkKafka { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1 ); env.enableCheckpointing(2000 , CheckpointingMode.EXACTLY_ONCE); SingleOutputStreamOperator<String> sensorDS = env .socketTextStream("hadoop102" , 7777 ); KafkaSink<String> kafkaSink = KafkaSink.<String>builder() .setBootstrapServers("hadoop102:9092,hadoop103:9092,hadoop104:9092" ) .setRecordSerializer( KafkaRecordSerializationSchema.<String>builder() .setTopic("ws" ) .setValueSerializationSchema(new SimpleStringSchema()) .build() ) .setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE) .setTransactionalIdPrefix("atguigu-" ) .setProperty(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, 10 *60 *1000 +"" ) .build(); sensorDS.sinkTo(kafkaSink); env.execute(); } }
自定义序列化器,实现带 key 的 record:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 public class SinkKafkaWithKey { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1 ); env.enableCheckpointing(2000 , CheckpointingMode.EXACTLY_ONCE); env.setRestartStrategy(RestartStrategies.noRestart()); SingleOutputStreamOperator<String> sensorDS = env .socketTextStream("hadoop102" , 7777 ); KafkaSink<String> kafkaSink = KafkaSink.<String>builder() .setBootstrapServers("hadoop102:9092,hadoop103:9092,hadoop104:9092" ) .setRecordSerializer( new KafkaRecordSerializationSchema<String>() { @Nullable @Override public ProducerRecord<byte [], byte []> serialize(String element, KafkaSinkContext context, Long timestamp) { String[] datas = element.split("," ); byte [] key = datas[0 ].getBytes(StandardCharsets.UTF_8); byte [] value = element.getBytes(StandardCharsets.UTF_8); return new ProducerRecord<>("ws" , key, value); } } ) .setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE) .setTransactionalIdPrefix("atguigu-" ) .setProperty(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, 10 * 60 * 1000 + "" ) .build(); sensorDS.sinkTo(kafkaSink); env.execute(); } }
运行代码,在 Linux 主机启动一个消费者,查看是否收到数据
1 bin/kafka-console-consumer.sh --bootstrap-server hadoop102:9092 --topic ws
4.4、输出到 MySQL(JDBC)
添加依赖
1 2 3 4 5 6 7 8 9 10 11 <!--mysql驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.27</version> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-connector-jdbc</artifactId> <version>3.1.0-1.17</version> </dependency>
启动 MySQL,在 test 库下建表
1 2 3 4 5 6 CREATE TABLE `ws` ( `id` varchar (100 ) NOT NULL , `ts` bigint (20 ) DEFAULT NULL , `vc` int (11 ) DEFAULT NULL , PRIMARY KEY (`id` ) ) ENGINE =InnoDB DEFAULT CHARSET =utf8
输出到 MySQL 的示例代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 public class SinkMySQL { public static void main (String[] args) throws Exception { StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.setParallelism(1 ); SingleOutputStreamOperator<WaterSensor> sensorDS = env .socketTextStream("hadoop102" , 7777 ) .map(new WaterSensorMapFunction()); SinkFunction<WaterSensor> jdbcSink = JdbcSink.sink( "insert into ws values(?,?,?)" , new JdbcStatementBuilder<WaterSensor>() { @Override public void accept (PreparedStatement preparedStatement, WaterSensor waterSensor) throws SQLException { preparedStatement.setString(1 , waterSensor.getId()); preparedStatement.setLong(2 , waterSensor.getTs()); preparedStatement.setInt(3 , waterSensor.getVc()); } }, JdbcExecutionOptions.builder() .withMaxRetries(3 ) .withBatchSize(100 ) .withBatchIntervalMs(3000 ) .build(), new JdbcConnectionOptions.JdbcConnectionOptionsBuilder() .withUrl("jdbc:mysql://hadoop102:3306/test?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=UTF-8" ) .withUsername("root" ) .withPassword("000000" ) .withConnectionCheckTimeoutSeconds(60 ) .build() ); sensorDS.addSink(jdbcSink); env.execute(); } }
运行代码,用客户端连接 MySQL,查看是否成功写入数据。
4.4、自定义 Sink 输出 如果我们想将数据存储到我们自己的存储设备中,而 Flink 并没有提供可以直接使用的连接器,就只能自定义Sink 进行输出了。与 Source 类似,Flink 为我们提供了通用的 SinkFunction 接口和对应的 RichSinkDunction 抽象类,只要实现它,通过简单地调用 DataStream 的 .addSink()
方法就可以自定义写入任何外部存储。
1 stream.addSink(new MySinkFunction<String>());
在实现 SinkFunction 的时候,需要重写的一个关键方法 invoke(),在这个方法中我们就可以实现将流里的数据发送出去的逻辑。 这种方式比较通用,对于任何外部存储系统都有效;不过自定义 Sink 想要实现状态一致性并不容易,所以一般只在没有其它选择时使用。实际项目中用到的外部连接器 Flink 官方基本都已实现,而且在不断地扩充,因此自定义的场景并不常见。