目录
Flink 有非常灵活的分层 API 设计,其中的核心层就是 DataStream/DataSet API。由于新版 本已经实现了流批一体,DataSet API 将被弃用,官方推荐统一使用 DataStream API 处理流数 据和批数据。由于内容较多,我们将会用几章的篇幅来做详细讲解,本章主要介绍基本的 DataStream API 用法。 DataStream(数据流)本身是 Flink 中一个用来表示数据集合的类(Class),我们编写的 Flink 代码其实就是基于这种数据类型的处理,所以这套核心 API 就以 DataStream 命名。对于 批处理和流处理,我们都可以用这同一套 API 来实现。 DataStream 在用法上有些类似于常规的 Java 集合,但又有所不同。我们在代码中往往并 不关心集合中具体的数据,而只是用 API 定义出一连串的操作来处理它们;这就叫作数据流 的“转换”(transformations)。 一个 Flink 程序,其实就是对 DataStream 的各种转换。具体来说,代码基本上都由以下几部分组成:
⚫ 获取执行环境(execution environment)
⚫ 读取数据源(source)
⚫ 定义基于数据的转换操作(transformations)
⚫ 定义计算结果的输出位置(sink)
⚫ 触发程序执行(execute)
其中,获取环境和触发执行,都可以认为是针对执行环境的操作。所以本章我们就从执行 环境、数据源(source)、转换操作(transformation)、输出(sink)四大部分,对常用的 DataStream API 做基本介绍。
1、 执行环境
Flink 程序可以在各种上下文环境中运行:我们可以在本地 JVM 中执行程序,也可以提交 到远程集群上运行。 不同的环境,代码的提交运行的过程会有所不同。这就要求我们在提交作业执行计算时, 首先必须获取当前 Flink 的运行环境,从而建立起与 Flink 框架之间的联系。只有获取了环境上下文信息,才能将具体的任务调度到不同的 TaskManager 执行。
1.1 创建执行环境
编 写 Flink 程 序 的 第 一 步 , 就 是 创 建 执 行 环 境 。 我 们 要 获 取 的 执 行 环 境 , 是 StreamExecutionEnvironment 类的对象,这是所有 Flink 程序的基础。在代码中创建执行环境的 方式,就是调用这个类的静态方法,具体有以下三种。
(1)getExecutionEnvironment
最简单的方式,就是直接调用 getExecutionEnvironment 方法。它会根据当前运行的上下文 直接得到正确的结果:如果程序是独立运行的,就返回一个本地执行环境;如果是创建了 jar 包,然后从命令行调用它并提交到集群执行,那么就返回集群的执行环境。也就是说,这个方 法会根据当前运行的方式,自行决定该返回什么样的运行环境。
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
这种方式不需要我们额外做判断,用起来简单高效,是最常用的一种创建执行环境的方式
(2)createLocalEnvironment
这个方法返回一个本地执行环境。可以在调用时传入一个参数,指定默认的并行度;如果不传入,则默认并行度就是本地CPU核心数
StreamExecutionEnvironment localEnv =
StreamExecutionEnvironment.createLocalEnvironment();
(3)createRemoteEnvironment
这个方法返回集群执行环境。需要在调用时指定 JobManager 的主机名和端口号,并指定 要在集群中运行的 Jar 包
StreamExecutionEnvironment remoteEnv = StreamExecutionEnvironment
.createRemoteEnvironment(
"host", // JobManager 主机名
1234, // JobManager 进程端口号
"path/to/jarFile.jar" // 提交给 JobManager 的 JAR 包
);
在获取到程序执行环境后,我们还可以对执行环境进行灵活的设置。比如可以全局设置程 序的并行度、禁用算子链,还可以定义程序的时间语义、配置容错机制。关于时间语义和容错 机制,我们会在后续的章节介绍。
1.2 执行模式
上节中我们获取到的执行环境,是一个 StreamExecutionEnvironment,顾名思义它应该是 做流处理的。那对于批处理,又应该怎么获取执行环境呢? 在之前的 Flink 版本中,批处理的执行环境与流处理类似,是调用类 ExecutionEnvironment 的静态方法,返回它的对象:
// 批处理环境
ExecutionEnvironment batchEnv = ExecutionEnvironment.getExecutionEnvironment();
// 流处理环境
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
基于 ExecutionEnvironment 读入数据创建的数据集合,就是 DataSet;对应的调用的一整 套转换方法,就是 DataSet API。这些我们在第二章的批处理 word count 程序中已经有了基本 了解。
而从 1.12.0 版本起,Flink 实现了 API 上的流批统一。DataStream API 新增了一个重要特 性:可以支持不同的“执行模式”(execution mode),通过简单的设置就可以让一段 Flink 程序 在流处理和批处理之间切换。这样一来,DataSet API 也就没有存在的必要了。
流执行模式
这是 DataStream API 最经典的模式,一般用于需要持续实时处理的无界数据流。默认情况下,程序使用的就是 STREAMING 执行模式。
批执行模式
专门用于批处理的执行模式, 这种模式下,Flink 处理作业的方式类似于 MapReduce 框架。 对于不会持续计算的有界数据,我们用这种模式处理会更方便。
自动模式
在这种模式下,将由程序根据输入数据源是否有界,来自动选择执行模式。
1.BATCH模式的配置方法
由于 Flink 程序默认是 STREAMING 模式,我们这里重点介绍一下 BATCH 模式的配置。 主要有两种方式:
(1)通过命令行配置
bin/flink run -Dexecution.runtime-mode=BATCH ...
在提交作业时,增加 execution.runtime-mode 参数,指定值为 BATCH。
(2)通过代码配置
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
env.setRuntimeMode(RuntimeExecutionMode.BATCH);
在代码中,直接基于执行环境调用 setRuntimeMode 方法,传入 BATCH 模式。 建议: 不要在代码中配置,而是使用命令行。这同设置并行度是类似的:在提交作业时指 定参数可以更加灵活,同一段应用程序写好之后,既可以用于批处理也可以用于流处理。而在 代码中硬编码(hard code)的方式可扩展性比较差,一般都不推荐。
2.什么时候选择BATCH模式
我们知道,Flink 本身持有的就是流处理的世界观,即使是批量数据,也可以看作“有界 流”来进行处理。所以 STREAMING 执行模式对于有界数据和无界数据都是有效的;而 BATCH 模式仅能用于有界数据。 看起来 BATCH 模式似乎被 STREAMING 模式全覆盖了,那还有必要存在吗?我们能不 能所有情况下都用流处理模式呢? 当然是可以的,但是这样有时不够高效。 我们可以仔细回忆一下 word count 程序中,批处理和流处理输出的不同:在 STREAMING 模式下,每来一条数据,就会输出一次结果(即使输入数据是有界的);而 BATCH 模式下, 只有数据全部处理完之后,才会一次性输出结果。最终的结果两者是一致的,但是流处理模式 会将更多的中间结果输出。在本来输入有界、只希望通过批处理得到最终的结果的场景下, STREAMING 模式的逐个输出结果就没有必要了。 所以总结起来,一个简单的原则就是:用 BATCH 模式处理批量数据,用 STREAMING 模式处理流式数据。因为数据有界的时候,直接输出结果会更加高效;而当数据无界的时候, 我 们没得选择——只有 STREAMING 模式才能处理持续的数据流。 72 当然,在后面的示例代码中,即使是有界的数据源,我们也会统一用 STREAMING 模式 处理。这是因为我们的主要目标还是构建实时处理流数据的程序,有界数据源也只是我们用来 测试的手段。
1.3 触发程序执行
有了执行环境,我们就可以构建程序的处理流程了:基于环境读取数据源,进而进行各种 转换操作,最后输出结果到外部系统。 需要注意的是,写完输出(sink)操作并不代表程序已经结束。因为当 main()方法被调用 时,其实只是定义了作业的每个执行操作,然后添加到数据流图中;这时并没有真正处理数据 ——因为数据可能还没来。Flink 是由事件驱动的,只有等到数据到来,才会触发真正的计算, 这也被称为“延迟执行”或“懒执行”(lazy execution)。 所以我们需要显式地调用执行环境的 execute()方法,来触发程序执行。execute()方法将一 直等待作业完成,然后返回一个执行结果(JobExecutionResult)。
env.execute();
2、源算子(Source)
创建业务环境后,就可以构建数据处理的业务逻辑了,,本节主要讲解Flink的源算子。想要处理数据,先得有数据,所以首要任务就是把数据读进来。
Flink 可以从各种来源获取数据,然后构建 DataStream 进行转换处理。一般将数据的输入 来源称为数据源(data source),而读取数据的算子就是源算子(source operator)。所以,source 就是我们整个处理程序的输入端。 Flink 代码中通用的添加 source 的方式,是调用执行环境的 addSource()方法:
DataStream<String> stream = env.addSource(...);
方法传入一个对象参数,需要实现 SourceFunction 接口;返回 DataStreamSource。这里的 DataStreamSource 类继承自 SingleOutputStreamOperator 类,又进一步继承自 DataStream。所以 很明显,读取数据的 source 操作是一个算子,得到的是一个数据流(DataStream)。 这里可能会有些麻烦:传入的参数是一个“源函数”(source function),需要实现 SourceFunction 接口。这是何方神圣,又该怎么实现呢? 自己去实现它显然不会是一件容易的事。好在 Flink 直接提供了很多预实现的接口,此外 还有很多外部连接工具也帮我们实现了对应的 source function,通常情况下足以应对我们的实际需求。接下来我们就详细展开讲解。
2.1 准备工作
为了更好地理解,我们先构建一个实际应用场景。比如网站的访问操作,可以抽象成一个 三元组(用户名,用户访问的 urrl,用户访问 url 的时间戳),所以在这里,我们可以创建一个 类 Event,将用户行为包装成它的一个对象。Event 包含了以下一些字段
import java.sql.Timestamp;
public class Event {
public String user;
public String url;
public Long timestamp;
public Event() {
}
public Event(String user, String url, Long timestamp) {
this.user = user;
this.url = url;
this.timestamp = timestamp;
}
@Override
public String toString() {
return "Event{" +
"user='" + user + '\'' +
", url='" + url + '\'' +
", timestamp=" + new Timestamp(timestamp) +
'}';
}
}
注意:
(1)类是公有的
(2)有 无参构造函数
(3)属性都是public
(4)所有属性都是可以序列化的
Flink 会把这样的类作为一种特殊的 POJO 数据类型来对待,方便数据的解析和序列化。 另外我们在类中还重写了 toString 方法,主要是为了测试输出显示更清晰。关于 Flink 支持的 数据类型,我们会在后面章节做详细说明。
我们这里自定义的 Event POJO 类会在后面的代码中频繁使用,所以在后面的代码中碰到 Event,把这里的 POJO 类导入就好了。
2.2 从集合中读取数据
最简单的读取数据的方式,就是在代码中直接创建一个 Java 集合,然后调用执行环境的 fromCollection 方法进行读取。这相当于将数据临时存储到内存中,形成特殊的数据结构后, 作为数据源使用,一般用于测试
public static void main(String[] args) throws Exception {
//环境
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
//并行度
env.setParallelism(1);
ArrayList<Event> clicks = new ArrayList<>();
clicks.add(new Event("Mary","./home",1000L));
clicks.add(new Event("Bob","./cart",2000L));
DataStream<Event> stream = env.fromCollection(clicks);
stream.print();
env.execute();
}
也可以不构架集合,直接将元素列举出来,调用fromElements 方法进行数据读取
DataStreamSource<Event> stream2 = env.fromElements(
new Event("Mary", "./home", 1000L),
new Event("Bob", "./cart", 2000L)
);
2.3 从文件读取数据
真正的实际应用中,自然不会直接将数据写在代码中。通常情况下,我们会从存储介质中获取数据,一个比较常见的方式就是读取日志文件。这也是批处理中最常见的读取方式。
DataStream<String> stream = env.readTextFile("clicks.csv");
说明:
⚫ 参数可以是目录,也可以是文件;
⚫ 路径可以是相对路径,也可以是绝对路径;
⚫ 相对路径是从系统属性 user.dir 获取路径: idea 下是 project 的根目录, standalone 模式 下是集群节点根目录;
⚫ 也可以从 hdfs 目录下读取, 使用路径 hdfs://..., 由于 Flink 没有提供 hadoop 相关依赖, 需要 pom 中添加相关依赖:
<dependency>
<groupId>org.apache.hadoop</groupId>
<artifactId>hadoop-client</artifactId>
<version>2.7.5</version>
<scope>provided</scope>
</dependency>
2.4 从Socket读取数据
不论从集合还是文件,我们读取的其实都是有界数据。在流处理的场景中,数据往往是无 界的。这时又从哪里读取呢? 一个简单的方式,就是我们之前用到的读取 socket 文本流。这种方式由于吞吐量小、稳 定性较差,一般也是用于测试。
DataStream<String> stream = env.socketTextStream("localhost", 7777);
2.5 从Kafka读取数据
Kafka 作为分布式消息传输队列,是一个高吞吐、易于扩展的消息系统。而消息队列的传 输方式,恰恰和流处理是完全一致的。所以可以说 Kafka 和 Flink 天生一对,是当前处理流式 数据的双子星。在如今的实时流处理应用中,由 Kafka 进行数据的收集和传输,Flink 进行分 析计算,这样的架构已经成为众多企业的首选
略微遗憾的是,与 Kafka 的连接比较复杂,Flink 内部并没有提供预实现的方法。所以我 们只能采用通用的 addSource 方式、实现一个 SourceFunction 了。 好在Kafka与Flink确实是非常契合,所以Flink官方提供了连接工具flink-connector-kafka, 直接帮我们实现了一个消费者 FlinkKafkaConsumer,它就是用来读取 Kafka 数据的 SourceFunction。
方提供的是一个通用的 Kafka 连接器,它会自动跟踪最新版本的 Kafka 客户端。目前最新版本 只支持 0.10.0 版本以上的 Kafka,这里我们需要导入的依赖如下
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-kafka_${scala.binary.version}</artifactId>
<version>${flink.version}</version>
</dependency>
然后调用 env.addSource(),传入 FlinkKafkaConsumer 的对象实例就可以了。
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 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();
}
}
创建FlinkKafkaConsumer 时需要传入三个参数:
⚫ 第一个参数 topic,定义了从哪些主题中读取数据。可以是一个 topic,也可以是 topic 列表,还可以是匹配所有想要读取的 topic 的正则表达式。当从多个 topic 中读取数据 时,Kafka 连接器将会处理所有 topic 的分区,将这些分区的数据放到一条流中去。
⚫ 第二个参数是一个 DeserializationSchema 或者 KeyedDeserializationSchema。Kafka 消 息被存储为原始的字节数据,所以需要反序列化成 Java 或者 Scala 对象。上面代码中 使用的 SimpleStringSchema,是一个内置的 DeserializationSchema,它只是将字节数 组简单地反序列化成字符串。DeserializationSchema 和 KeyedDeserializationSchema 是 公共接口,所以我们也可以自定义反序列化逻辑。
⚫ 第三个参数是一个 Properties 对象,设置了 Kafka 客户端的一些属性。
2.6 自定义Source
大多数情况下,前面的数据源已经能够满足需要。但是凡事总有例外,如果遇到特殊情况, 我们想要读取的数据源来自某个外部系统,而 flink 既没有预实现的方法、也没有提供连接器, 又该怎么办呢?
那就只好自定义实现 SourceFunction 了。
接下来我们创建一个自定义的数据源,实现 SourceFunction 接口。主要重写两个关键方法: run()和 cancel()。
⚫ run()方法:使用运行时上下文对象(SourceContext)向下游发送数据;
⚫ cancel()方法:通过标识位控制退出循环,来达到中断数据源的效果。 代码如下: 我们先来自定义一下数据源:
import org.apache.flink.streaming.api.functions.source.SourceFunction;
import java.util.Calendar;
import java.util.Random;
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;
}
}
这个数据源后面会频繁用到,所有涉及到ClickSource()数据源,使用上面的代码就可以了
有了自定义的source function,接下来只需要调用addSource()就可以读取自定义的数据源了
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
public class SourceCustom {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
//有了自定义的 source function,调用 addSource 方法
DataStreamSource<Event> stream = env.addSource(new ClickSource());
stream.print("SourceCustom");
env.execute();
}
}
这里要注意的是 SourceFunction 接口定义的数据源,并行度只能设置为 1,如果数据源设 置为大于 1 的并行度,则会抛出异常。
所以如果我们想要自定义并行的数据源的话,需要使用 ParallelSourceFunction,示例程序 如下:
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.source.ParallelSourceFunction;
import java.util.Random;
public class ParallelSourceExample {
//main方法测试
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
env.addSource(new CustomSource()).setParallelism(2).print();
env.execute();
}
//实现ParallelSourceFunction接口
public static class CustomSource implements ParallelSourceFunction<Integer>
{
private boolean running = true;
private Random random = new Random();
@Override
public void run(SourceContext<Integer> sourceContext) throws Exception {
while (running) {
sourceContext.collect(random.nextInt());
}
}
@Override
public void cancel() {
running = false;
}
}
}
输出结果:
2.7 Flink 支持的数据类型
1)Flink 的类型系统
为什么会出现“不支持”的数据类型呢?因为 Flink 作为一个分布式处理框架,处理的是 以数据对象作为元素的流。如果用水流来类比,那么我们要处理的数据元素就是随着水流漂动 的物体。在这条流动的河里,可能漂浮着小木块,也可能行驶着内部错综复杂的大船。要分布 式地处理这些数据,就不可避免地要面对数据的网络传输、状态的落盘和故障恢复等问题,这 就需要对数据进行序列化和反序列化。小木块是容易序列化的;而大船想要序列化之后传输, 就需要将它拆解、清晰地知道其中每一个零件的类型。 为了方便地处理数据,Flink 有自己一整套类型系统。Flink 使用“类型信息” (TypeInformation)来统一表示数据类型。TypeInformation 类是 Flink 中所有类型描述符的基类。 它涵盖了类型的一些基本属性,并为每个数据类型生成特定的序列化器、反序列化器和比较器。
2)Flink 支持的数据类型
简单来说,对于常见的 Java 和 Scala 数据类型,Flink 都是支持的。Flink 在内部,Flink 对支持不同的类型进行了划分,这些类型可以在 Types 工具类中找到:
(1)基本类型
所有 Java 基本类型及其包装类,再加上 Void、String、Date、BigDecimal 和 BigInteger。
(2)数组类型 包括基本类型数组(PRIMITIVE_ARRAY)和对象数组(OBJECT_ARRAY)
(3)复合数据类型
⚫ Java 元组类型(TUPLE):这是 Flink 内置的元组类型,是 Java API 的一部分。最多 25 个字段,也就是从 Tuple0~Tuple25,不支持空字段
⚫ Scala 样例类及 Scala 元组:不支持空字段
⚫ 行类型(ROW):可以认为是具有任意个字段的元组,并支持空字段
⚫ POJO:Flink 自定义的类似于 Java bean 模式的类
(4)辅助类型
Option、Either、List、Map 等
(5)泛型类型(GENERIC)
Flink 支持所有的 Java 类和 Scala 类。不过如果没有按照上面 POJO 类型的要求来定义, 就会被 Flink 当作泛型类来处理。Flink 会把泛型类型当作黑盒,无法获取它们内部的属性;它 们也不是由 Flink 本身序列化的,而是由 Kryo 序列化的。
在这些类型中,元组类型和 POJO 类型最为灵活,因为它们支持创建复杂类型。而相比之下,POJO 还支持在键(key)的定义中直接使用字段名,这会让我们的代码可读性大大增加。 所以,在项目实践中,往往会将流处理程序中的元素类型定为 Flink 的 POJO 类型。
Flink 对 POJO 类型的要求如下:
⚫ 类是公共的(public)和独立的(standalone,也就是说没有非静态的内部类);
⚫ 类有一个公共的无参构造方法;
⚫ 类中的所有字段是 public 且非 final 的;或者有一个公共的 getter 和 setter 方法,这些方法需要符合 Java bean 的命名规范。
所以我们看到,之前的 UserBehavior,就是我们创建的符合 Flink POJO 定义的数据类型。
3)类型提示
Flink 还具有一个类型提取系统,可以分析函数的输入和返回类型,自动获取类型信息, 从而获得对应的序列化器和反序列化器。但是,由于 Java 中泛型擦除的存在,在某些特殊情 况下(比如 Lambda 表达式中),自动提取的信息是不够精细的——只告诉 Flink 当前的元素由 “船头、船身、船尾”构成,根本无法重建出“大船”的模样;这时就需要显式地提供类型信 息,才能使应用程序正常工作或提高其性能。
为了解决这类问题,Java API 提供了专门的“类型提示”(type hints)。
回忆一下之前的 word count 流处理程序,我们在将 String 类型的每个词转换成(word, count)二元组后,就明确地用 returns 指定了返回的类型。因为对于 map 里传入的 Lambda 表 达式,系统只能推断出返回的是 Tuple2 类型,而无法得到 Tuple2。只有显式地 告诉系统当前的返回类型,才能正确地解析出完整数据。
.map(word -> Tuple2.of(word, 1L))
.returns(Types.TUPLE(Types.STRING, Types.LONG));
这是一种比较简单的场景,二元组的两个元素都是基本数据类型。那如果元组中的一个元 素又有泛型,该怎么处理呢?
Flink 专门提供了 TypeHint 类,它可以捕获泛型的类型信息,并且一直记录下来,为运行 时提供足够的信息。我们同样可以通过.returns()方法,明确地指定转换之后的 DataStream 里元 素的类型。
returns(new TypeHint<Tuple2<Integer, SomeType>>(){})