Kafka Streams是用于构建关键任务实时应用程序和微服务的客户端库,输入和(或)输出数据存储在Kafka集群中。 Kafka Streams结合了在客户端编写和部署标准Java和Scala应用程序的简单性以及Kafka服务器端集群技术的优势,使这些应用程序具有高度可伸缩性,弹性,容错性,分布式等特性。 本快速入门示例将演示如何运行在此库中编码的流式应用程序。
这里是WordCountDemo示例代码的要点(转换为使用Java 8 lambda表达式以方便阅读)
// 用于String和Long类型的序列化器/反序列化器(serde)
final Serde stringSerde = Serdes.String();
final Serde longSerde = Serdes.Long();
// 从输入主题“streams-plaintext-input”构造一个“KStream”,
//其中消息值代表文本行(为了这个例子,我们忽略可能存储在消息键中的任何东西)。
KStream textLines = builder.stream("streams-plaintext-input",
Consumed.with(stringSerde, stringSerde);
KTable wordCounts = textLines
// 将每个文本行按空格拆分为单词。
.flatMapValues(value -> Arrays.asList(value.toLowerCase().split("\\W+")))
// 将分本单词作为消息key分组
.groupBy((key, value) -> value)
// 统计每个单词(消息键)的出现次数。
.count()
//将运行计数作为更改日志流存储到输出主题。
//wordCounts.to(stringSerde, longSerde, "streams-wordcount-output");
wordCounts.toStream().to("streams-wordcount-output", Produced.with(Serdes.String(), Serdes.Long()));
它实现了WordCount算法,该算法从输入的文本计算出一个词出现次数。 但是,与其他可能在之前看到的对静态数据进行操作的WordCount示例不同,WordCount demo应用程序的做法稍有不同,因为它被设计为在无限的无限数据流上运行。 与静态变体类似,它是一种有状态的算法,用于跟踪和更新单词的计数。 但是,由于它必须承担可能无限的输入数据,所以它将周期性地输出其当前状态和结果,同时继续处理更多数据,因为它不知道何时处理了“全部”输入数据。
假设已经启动了kafka和zookeeper
接下来,我们创建名为streams-plaintext-input的输入主题和名为streams-wordcount-output的输出主题:
>bin/kafka-topics.sh –create \
–zookeeper localhost:2181 \
–replication-factor 1 \
–partitions 1 \
–topic streams-plaintext-input
Created topic "streams-plaintext-input".
>bin/kafka-topics.sh –create \
–zookeeper localhost:2181 \
–replication-factor 1 \
–partitions 1 \
–topic streams-wordcount-output
–config cleanup.policy=compact
Created topic "streams-wordcount-output".
创建的主题可以用相同的kafka主题工具来查看:
>bin/kafka-topics.sh –zookeeper localhost:2181 –describe
Topic:streams-plaintext-input PartitionCount:1 ReplicationFactor:1 Configs:
Topic: streams-plaintext-input Partition: 0 Leader: 0 Replicas: 0 Isr: 0
Topic:streams-wordcount-output PartitionCount:1 ReplicationFactor:1 Configs:
Topic: streams-wordcount-output Partition: 0 Leader: 0 Replicas: 0 Isr: 0
以下命令启动WordCount演示应用程序:
> bin/kafka-run-class.sh org.apache.kafka.streams.examples.wordcount.WordCountDemo
演示应用程序将从输入主题stream-plaintext-input中读取,对每个读取的消息执行WordCount算法的计算,并将其当前结果连续写入输出主题流-wordcount-output。因此,除了日志条目外,不会有任何STDOUT输出,因为结果会写回到Kafka中。
现在我们可以在一个终端的控制台启动生产者来为这个主题写入一些输入数据:
> bin/kafka-console-producer.sh –broker-list localhost:9092 –topic streams-plaintext-input
并通过在单独的终端中使用控制台客户端读取其输出主题来检查WordCount演示应用程序的输出:
>bin/kafka-console-consumer.sh –bootstrap-server localhost:9092 \
–topic streams-wordcount-output \
–from-beginning \
–formatter kafka.tools.DefaultMessageFormatter \
–property print.key=true \
–property print.value=true \
–property key.deserializer=org.apache.kafka.common.serialization.StringDeserializer \
–property value.deserializer=org.apache.kafka.common.serialization.LongDeserializer
现在我们通过输入一行文本,然后按下,将控制台生产者的一些消息写入输入主题streams-plaintext-input。这将向输入主题发送一个新消息,消息key为空,消息值为刚刚输入的字符串编码文本行(实际上,应用程序的输入数据通常会连续流入Kafka,而不是手动输入,就像我们在这个快速入门一样):
>bin/kafka-console-producer.sh –broker-list localhost:9092 –topic streams-plaintext-input
all streams lead to kafka
此消息将由Wordcount应用程序处理,以下输出数据将被写入到streams-wordcount-output主题中并由控制台消费者打印:
>bin/kafka-console-consumer.sh –bootstrap-server localhost:9092
–topic streams-wordcount-output \
–from-beginning \
–formatter kafka.tools.DefaultMessageFormatter \
–property print.key=true \
–property print.value=true \
–property key.deserializer=org.apache.kafka.common.serialization.StringDeserializer \
–property value.deserializer=org.apache.kafka.common.serialization.LongDeserializer
all 1
streams 1
lead 1
to 1
kafka 1
这里,第一列是java.lang.String格式的Kafka消息键,表示正在计数的单词,第二列是java.lang.Longformat中的消息值,表示单词的最新计数。
现在让我们继续用控制台生产者写入一个更多的消息到输入主题streams-plaintext-input中。
>bin/kafka-console-producer.sh –broker-list localhost:9092 –topic streams-plaintext-input
all streams lead to kafka
hello kafka streams
在控制台使用者正在运行的其他终端中,您将观察到WordCount应用程序编写了新的输出数据:
>bin/kafka-console-consumer.sh –bootstrap-server localhost:9092
–topic streams-wordcount-output \
–from-beginning \
–formatter kafka.tools.DefaultMessageFormatter \
–property print.key=true \
–property print.value=true \
–property key.deserializer=org.apache.kafka.common.serialization.StringDeserializer \
–property value.deserializer=org.apache.kafka.common.serialization.LongDeserializer
all 1
streams 1
lead 1
to 1
kafka 1
hello 1
kafka 2
streams 2
在这里,最后一行打印出的数字kafka 2和streams 2表示更新了键kafka和streams的值已经从1增加到2。每当你向输入主题写入更多的输入消息时,你将观察到新的消息被添加到流 streams-wordcount-output主题,表示由WordCount应用程序计算出的最近的字数。让我们输入一个最终的输入文本行“join kafka summit”,然后在控制台生产者中输入主题streams-wordcount-input作为例子的结束:
>bin/kafka-console-producer.sh –broker-list localhost:9092 –topic streams-wordcount-input
all streams lead to kafka
hello kafka stream
join kafka summit
stream-wordcount-output主题随后将显示相应的更新的字数(参见最后三行):
>bin/kafka-console-consumer.sh –bootstrap-server localhost:9092
–topic streams-wordcount-output \
–from-beginning \
–formatter kafka.tools.DefaultMessageFormatter \
–property print.key=true \
–property print.value=true \
–property key.deserializer=org.apache.kafka.common.serialization.StringDeserializer \
–property value.deserializer=org.apache.kafka.common.serialization.LongDeserializer
all 1
streams 1
lead 1
to 1
kafka 1
hello 1
kafka 2
streams 2
join 1
kafka 3
summit 1
可以看出,Wordcount应用程序的输出实际上是连续的更新流(相当于structed streaming的update模式),其中每个输出记录(即,上面原始输出中的每行)是单个单词的更新计数,也就是诸如“kafka”的记录key。 对于具有相同key的多个记录,每个以后的记录都是前一个记录的更新。
下面的两个图表说明了幕后发生的事情。 第一列显示KTable
您现在可以通过Ctrl-C按顺序停止控制台使用者,控制台生产者,Wordcount应用程序,Kafka代理和ZooKeeper服务器。
在本指南中,我们将从头开始设置您自己的项目,使用Kafka的Streams API编写流处理应用程序。 强烈建议先阅读上一节,了解如何运行使用Kafka Streams编写的Streams应用程序。
我们将使用Kafka Streams Maven原型来创建Streams项目结构,其中包含以下命令:
mvn archetype:generate \
-DarchetypeGroupId=org.apache.kafka \
-DarchetypeArtifactId=streams-quickstart-java \
-DarchetypeVersion= 1.0.0 \
-DgroupId=streams.examples \
-DartifactId=streams.examples \
-Dversion=0.1 \
-Dpackage=myapps
如果你喜欢,你可以为groupId,artifactId和package参数使用不同的值。 假设使用了上述参数值,这个命令将创建一个如下所示的项目结构:
> tree streams.examples
streams-quickstart
|-- pom.xml
|-- src
|-- main
|-- java
| |-- myapps
| |-- LineSplit.java
| |-- Pipe.java
| |-- WordCount.java
|-- resources
|-- log4j.properties
项目中包含的pom.xml文件已经定义了Streams依赖项,在src / main / java下已经有几个用Streams库编写的示例程序。 既然我们要从头开始编写这样的程序,现在我们可以删除这些例子:
>cd streams-quickstart
>rm src/main/java/myapps/*.java
编写第一个Streams应用程序:Pipe
现在是编码时间! 随意打开你最喜欢的IDE,导入这个Maven项目,或者直接打开一个文本编辑器,在src/main/java下创建一个java文件Pipe.java:
package myapps;
public class Pipe {
public static void main(String[] args) throws Exception {
}
}
我们要编写这个管道程序的主要功能。编写Streams应用程序的第一步是创建一个java.util.Properties来映射指定StreamsConfig中定义的不同Streams执行配置值。 您需要设置的几个重要配置是:StreamsConfig.BOOTSTRAP_SERVERS_CONFIG,它指定用于建立到Kafka集群的初始连接的主机/端口对列表,以及StreamsConfig.APPLICATION_ID_CONFIG,它提供了流应用程序的唯一标识符,以区分在同一个的Kafka集群上运行的其他流处理程序:
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "streams-pipe");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
另外,您可以在同一个properties中自定义其他配置,例如记录键值对的默认序列化和反序列化库:
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());
有关Kafka Streams配置的完整列表,请参阅3.6。
接下来我们将定义Streams应用程序的计算逻辑。 在Kafka流中,这个计算逻辑被定义为连接的处理器节点的拓扑。我们可以使用拓扑构建器来构建这样的拓扑。
final StreamsBuilder builder = new StreamsBuilder();
然后使用此拓扑构建器从名为streams-plaintext-input的Kafka主题创建流:
KStream<String, String> source = builder.stream("streams-plaintext-input");
现在我们得到一个KStream,它不断从源Kafka主题流 - streams-plaintext-input读取记录。 记录被组织为字符串键入的键值对。 我们可以用这个流做的最简单的事情就是把它写入另一个Kafka主题,例如:streams-pipe-output:
source.to("streams-pipe-output");
请注意,我们也可以将上面两行连接成一行:
builder.stream("streams-plaintext-input").to("streams-pipe-output");
我们可以通过执行以下操作来检查此构建器创建的拓扑结构类型:
final Topology topology = builder.build();
打印其描述:
System.out.println(topology.describe());
它会输出以下信息:
> mvn clean package
> mvn exec:java -Dexec.mainClass=myapps.Pipe
Sub-topologies:
Sub-topology: 0
Source: KSTREAM-SOURCE-0000000000(topics: streams-plaintext-input) --> KSTREAM-SINK-0000000001
Sink: KSTREAM-SINK-0000000001(topic: streams-pipe-output) <-- KSTREAM-SOURCE-0000000000
Global Stores:
none
如上所示,说明构建的拓扑具有两个处理器节点,source节点KSTREAM-SOURCE-0000000000和sink节点KSTREAM-SINK-0000000001。 KSTREAM-SOURCE-0000000000连续读取来自Kafka topic流的记录 - 纯文本输入并将它们传送到其下游节点KSTREAM-SINK-0000000001; KSTREAM-SINK-0000000001会将其接收到的每条记录写入另一个Kafka主题流管道输出( - >和< - 箭头指示该节点的下游和上游处理器节点,即在拓扑图中的“children”和“parents “)。 它还说明这个简单的拓扑没有与之相关的全局状态存储(我们将在后面的章节中更多地讨论状态存储)。
请注意,我们可以像在上面那样,在构建拓扑时打印拓扑结构,因此作为用户,您可以交互式地“尝试并查看”拓扑中定义的计算逻辑,直到您满意为止。 假设我们已经完成了这个简单的拓扑结构,只是将数据从一个Kafka主题以无限的流式传输到另一个Kafka主题,现在我们可以使用我们刚刚构建的两个组件构建Streams客户端:configuration map and the topology objec,(也可以从props map构造一个StreamsConfig对象,然后把这个对象传递给构造函数,KafkaStreams重载构造函数来取任何一种类型)。
//final KafkaStreams streams = new KafkaStreams(builder, props);
final KafkaStreams streams = new KafkaStreams(topology, props);
通过调用它的start()函数,我们可以触发这个客户端的执行。在这个客户端上调用close()之前,执行不会停止。例如,我们可以添加一个带有倒数锁存器的关闭钩子来捕获用户中断,并在终止这个程序时关闭客户端:
final CountDownLatch latch = new CountDownLatch(1);
// attach shutdown handler to catch control-c
Runtime.getRuntime().addShutdownHook(new Thread("streams-shutdown-hook") {
@Override
public void run() {
streams.close();
latch.countDown();
}
});
try {
streams.start();
latch.await();
} catch (Throwable e) {
System.exit(1);
}
System.exit(0);
到目前为止,完整的代码如下所示:
package myapps;
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.Topology;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
public class Pipe {
public static void main(String[] args) throws Exception {
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "streams-pipe");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());
final StreamsBuilder builder = new StreamsBuilder();
builder.stream("streams-plaintext-input").to("streams-pipe-output");
final Topology topology = builder.build();
final KafkaStreams streams = new KafkaStreams(topology, props);
final CountDownLatch latch = new CountDownLatch(1);
// attach shutdown handler to catch control-c
Runtime.getRuntime().addShutdownHook(new Thread("streams-shutdown-hook") {
@Override
public void run() {
streams.close();
latch.countDown();
}
});
try {
streams.start();
latch.await();
} catch (Throwable e) {
System.exit(1);
}
System.exit(0);
}
}
如果您已经在localhost:9092上运行了Kafka broker,并且在该broker上创建了主题streams-plaintext-input和streams-pipe-output,则可以在IDE或命令行上使用Maven运行此代码:
> mvn clean package
> mvn exec:java -Dexec.mainClass=myapps.Pipe
我们已经学会了如何构建Streams客户端及其两个关键组件:StreamsConfig和TopologyBuilder。 现在让我们继续增加一些实际的处理逻辑,通过增加当前的拓扑结构。 我们可以先通过复制现有的Pipe.java类来创建另一个程序,并修改类名和StreamsConfig.APPLICATION_ID_CONFIG:
public class LineSplit {
public static void main(String[] args) throws Exception {
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "streams-linesplit");
// ...
}
}
由于每个流的源记录都是一个字符串类型的键值对,所以让我们将值字符串视为文本行,并用FlatMapValues运算符将其拆分为单词:
KStream source = builder.stream("streams-plaintext-input");
KStream words = source.flatMapValues(new ValueMapper>() {
@Override
public Iterable apply(String value) {
return Arrays.asList(value.split("\\W+"));
}
});
操作将源流作为输入,并通过按顺序处理源流中的每个记录并将其值每行分解为单词列表来生成一个名words的新流,并将每个单词作为新记录。 这是一个无状态的操作,不需要跟踪任何以前收到的记录或处理结果。 请注意,如果您使用的是JDK 8,则可以使用lambda表达式并简化上面的代码:
KStream source = builder.stream("streams-plaintext-input");
KStream words = source.flatMapValues(value -> Arrays.asList(value.split("\\W+")));
最后我们可以把这个单词流写回到另一个Kafka主题中,比如stream-linesplit-output。 再次,这两个步骤可以如下所示连接(假设使用lambda表达式):
KStream source = builder.stream("streams-plaintext-input");
source.flatMapValues(value -> Arrays.asList(value.split("\\W+")))
.to("streams-linesplit-output");
如果这里使用System.out.println(topology.describe())打印拓扑结构:
> mvn clean package
> mvn exec:java -Dexec.mainClass=myapps.LineSplit
Sub-topologies:
Sub-topology: 0
Source: KSTREAM-SOURCE-0000000000(topics: streams-plaintext-input) --> KSTREAM-FLATMAPVALUES-0000000001
Processor: KSTREAM-FLATMAPVALUES-0000000001(stores: []) --> KSTREAM-SINK-0000000002 <-- KSTREAM-SOURCE-0000000000
Sink: KSTREAM-SINK-0000000002(topic: streams-linesplit-output) <-- KSTREAM-FLATMAPVALUES-0000000001
Global Stores:
none
如上所示,一个新的处理器节点KSTREAM-FLATMAPVALUES-0000000001被注入到source节点和sink节点之间的拓扑中。它以source节点为父节点,sink节点为子节点。
完整的代码如下所示(假设使用lambda表达式):
package myapps;
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.Topology;
import org.apache.kafka.streams.kstream.KStream;
import java.util.Arrays;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
public class LineSplit {
public static void main(String[] args) throws Exception {
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "streams-linesplit");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());
final StreamsBuilder builder = new StreamsBuilder();
KStream source = builder.stream("streams-plaintext-input");
source.flatMapValues(value -> Arrays.asList(value.split("\\W+")))
.to("streams-linesplit-output");
final Topology topology = builder.build();
final KafkaStreams streams = new KafkaStreams(topology, props);
final CountDownLatch latch = new CountDownLatch(1);
// ... same as Pipe.java above
}
}
现在让我们进一步通过计算从源文本流中拆分出来的单词来为拓扑添加一些“有状态的”计算。 按照类似的步骤,我们创建另一个基于LineSplit.java类的程序:
public class WordCount {
public static void main(String[] args) throws Exception {
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "streams-wordcount");
// ...
}
}
为了计算单词,我们可以首先修改flatMapValues运算符,将它们全部作为小写字母(假设使用lambda表达式):
source.flatMapValues(new ValueMapper>() {
@Override
public Iterable apply(String value) {
return Arrays.asList(value.toLowerCase(Locale.getDefault()).split("\\W+"));
}
});
为了进行计数聚合,我们必须首先想到的是使用groupBy运算符。这个操作符生成一个新的分组流,然后可以由一个计数操作聚合,该计数操作在每个分组键上运行一个计数:
KTable counts =
source.flatMapValues(new ValueMapper>() {
@Override
public Iterable apply(String value) {
return Arrays.asList(value.toLowerCase(Locale.getDefault()).split("\\W+"));
}
})
.groupBy(new KeyValueMapper() {
@Override
public String apply(String key, String value) {
return value;
}
})
// 将结果物化到名为“counts-store”的KeyValueStore中。
// 物化存储始终是类型,因为这是最内层存储的格式。
.count(Materialized.byte[]>> as("counts-store"));
请注意,count运算符具有一个Materialized参数,该参数指定运行计数应存储在名为counts-store的状态存储中。 这个Counts存储可以实时查询,详细描述在开发者手册中。
我们还可以将计数KTable的更改日志流重新写入另一个Kafka主题,例如streams-wordcount-output。 请注意,这次值类型不再是String而是Long,所以默认的序列化类不再可写入Kafka。 我们需要为Long类型提供重写的序列化方法,否则会抛出运行时异常:
counts.toStream().to("streams-wordcount-output", Produced.with(Serdes.String(), Serdes.Long());
请注意,为了从主题 streams-wordcount-output读取changelog流,需要将值反序列化设置为org.apache.kafka.common.serialization.LongDeserializer。 假设可以使用JDK 8中的lambda表达式,上面的代码可以简化为:
KStream source = builder.stream("streams-plaintext-input");
source.flatMapValues(value -> Arrays.asList(value.toLowerCase(Locale.getDefault()).split("\\W+")))
.groupBy((key, value) -> value)
.count(Materialized.byte[]>>as("counts-store"))
.toStream()
.to("streams-wordcount-output", Produced.with(Serdes.String(), Serdes.Long());
完整的代码如下所示(假设使用lambda表达式):
package myapps;
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.Topology;
import org.apache.kafka.streams.kstream.KStream;
import java.util.Arrays;
import java.util.Locale;
import java.util.Properties;
import java.util.concurrent.CountDownLatch;
public class WordCount {
public static void main(String[] args) throws Exception {
Properties props = new Properties();
props.put(StreamsConfig.APPLICATION_ID_CONFIG, "streams-wordcount");
props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass());
props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass());
final StreamsBuilder builder = new StreamsBuilder();
KStream source = builder.stream("streams-plaintext-input");
source.flatMapValues(value -> Arrays.asList(value.toLowerCase(Locale.getDefault()).split("\\W+")))
.groupBy((key, value) -> value)
.count(Materialized.byte[]>>as("counts-store"))
.toStream()
.to("streams-wordcount-output", Produced.with(Serdes.String(), Serdes.Long());
final Topology topology = builder.build();
final KafkaStreams streams = new KafkaStreams(topology, props);
final CountDownLatch latch = new CountDownLatch(1);
// ... same as Pipe.java above
}
}