Kafka流和Quarkus:实时处理事件

395 阅读16分钟

Kafka流和Quarkus:实时处理事件

主要收获

  • Kafka流可以让你实时处理、转换、连接和操作Kafka事件。
  • Quarkus集成了Kafka流,所以你唯一需要做的就是定义拓扑结构。
  • Quarkus Dev模式通过提供一个Kafka集群来帮助测试Kafka Streams代码。
  • Kafka Streams互动查询是同步消费实时处理的方式。

在本系列的第一部分,我们了解了Apache KafkaQuarkus之间的整合,在那里我们开发了一个简单的应用程序,从两个Kafka主题产生和消费事件。

在那个例子中,我们模拟了一个电影流公司。我们在一个Kafka主题中存储了Movies,在另一个Kafka主题中,我们存储了用户停止观看电影时发生的每一个事件,并捕获了电影播放的时间。

下图显示了该应用的架构:

我们看到,消费消息是很简单的,只要产生了消息,你就能得到它们,但没有其他的。但是如果你需要对数据进行实时处理(例如过滤事件或操作事件),或者你需要在事件之间进行一些关联,仅仅使用Kafka-consuming API可能不是最好的方法,因为由此产生的代码会变得复杂。

Kafka流

Kafka Streams项目可以帮助你在事件产生时消费实时流,应用任何转换,连接流等,并可选择将新的数据表示写回主题。

Kafka流是无状态和有状态流应用的理想选择,它实现了基于时间的操作(例如围绕给定的时间段对事件进行分组),并且考虑到了Kafka生态系统中始终存在的可扩展性、可靠性和可维护性。

一个Kafka流由三个元素组成:输入(源处理器)、输出(汇处理器)和处理器(流处理器)。

源处理器:一个源处理器代表一个Kafka主题。一个源处理器将事件发送到一个或多个流处理器。

处理器:流处理器将转换/逻辑应用于输入流,如连接、分组、计数、映射等。一个流处理器可以连接到另一个流处理器和/或一个水槽处理器。

水槽处理器:一个水槽处理器代表输出数据,并连接到一个Kafka主题。

一个 拓扑结构是一个由源、处理器和汇组成的丙烯酸图(没有循环的图),然后传入一个Kafka Streams实例,该实例将开始执行拓扑结构。

Kafka Streams和Quarkus

Quarkus使用Quarkus KStreams扩展与Kafka Streams集成。

开始使用Quarkus

使用Quarkus最快捷的方法是通过开始页添加所需的依赖项。每个服务可能有不同的依赖性,你可以选择Java 11或Java 17。对于Quarkus和Kafka Streams的集成,你至少需要添加Kafka Streams扩展。

开发中的应用程序

正如本文开头提到的,在该系列的第一部分,我们开发了一个有两个Kafka主题的Movies流公司:一个用于存储电影列表,另一个主题用于存储每次用户停止观看电影时,存储电影播放的用户区域(事件的关键),还有电影的id和观看的时间作为值。

所有这些逻辑都是在Quarkus中开发的名为Movie Plays Producer的生产者服务中创建的。

此外,我们还开发了一个用Quakus开发的Movie Plays Consumer服务,该服务从两个主题中获取事件,并在控制台中显示这些事件(并作为HTTP服务器端事件)。

但是没有对数据进行处理;只是在收到数据时进行流式处理。如果我们想在movies和playtimemovies主题之间做一个连接,以获得每部电影的持续时间,而不是用id,而是用所有的电影信息,会发生什么?

只用Kafka消息来实现这个逻辑可能会成为一个复杂的任务,因为你需要把电影信息存储在一个Map上,然后,对于每一个新的playedmovie事件,进行匹配。

电影播放KStream

与其为每个用例手工编写代码,不如让我们看看如何使用Kafka流,以及它是如何与Quarkus集成来解决这个问题的。

创建项目

导航到Quarkus开始页,选择Apache Kafka Streams扩展,与Kafka Streams集成。然后选择RestEasy和RestEasy Jackson扩展,用于从/到JSON-Java Object-Byte Array的事件打包/解包。同时,取消勾选开始代码生成选项。

在下面的截图中,你可以看到它:

你可以选择跳过这个手动步骤,导航到Kafka流Quarkus生成器的链接,所有的依赖都被选中。然后按下Generate your application按钮,下载脚手架式应用程序的压缩文件。

解压缩文件并在你最喜欢的IDE中打开该项目。

开发

开发Kafka流时,首先要做的是创建 [Topology](https://kafka.apache.org/28/javadoc/org/apache/kafka/streams/Topology.html)实例并定义源、处理器和汇。

在Quarkus中,你只需要创建一个带有返回Topology 实例的方法的CDI类。

创建一个名为TopologyProducer 的新类,它将实现来自两个主题的消耗事件,并将它们连接起来。最后,将结果发送到一个水槽处理器,该处理器将结果以控制台输出的形式发送。

有一个元素还没有解释,但在这些用例中确实很有用,那就是Kafka表。

一个主题可以包含多个具有相同键的事件。例如,你可以用一个键插入一部电影,然后你可以用同一个键更新电影,创建一个新的事件:

但是,如果你想把一个电影主题和一个playtimemovies主题连接起来,应该使用哪个键1的事件?第一个还是第二个?在这个特定的案例中,应该是最新的那个,因为它包含了电影的最新版本。为了获得每个事件的最新版本,Kafka Streams有一个的概念(KTable/GlobalKTable)。

Kafka Streams将浏览一个给定的主题,获得每个事件的最新版本,并将其放在一个表实例中:

KafkaStream扩展并没有像Kafka消息集成那样自动注册一个 [SerDes](https://kafka.apache.org/23/javadoc/org/apache/kafka/common/serialization/Serdes.html)类,所以我们需要在拓扑结构中手动注册它们:

package org.acme;
 
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.inject.Produces;
 
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.streams.KeyValue;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.Topology;
import org.apache.kafka.streams.kstream.Consumed;
import org.apache.kafka.streams.kstream.GlobalKTable;
import org.apache.kafka.streams.kstream.KStream;
import org.apache.kafka.streams.kstream.Printed;
 
import io.quarkus.kafka.client.serialization.ObjectMapperSerde;
 
@ApplicationScoped
public class TopologyProducer {
  
   private static final String MOVIES_TOPIC = "movies";
   private static final String PLAY_MOVIES_TOPIC = "playtimemovies";
 
   @Produces
   public Topology getTopCharts() {
 
       final StreamsBuilder builder = new StreamsBuilder();
 
// SerDes for Movie and PlayedMovie
      
       final ObjectMapperSerde<Movie> movieSerder = new ObjectMapperSerde<>(Movie.class);
       final ObjectMapperSerde<MoviePlayed> moviePlayedSerder = new ObjectMapperSerde<>(MoviePlayed.class);
 
	// Creation of a Global Kafka Table for Movies topic
 
       final GlobalKTable<Integer, Movie> moviesTable = builder.globalTable(
               MOVIES_TOPIC,
               Consumed.with(Serdes.Integer(), movieSerder));
 
	// Stream connected to playtimemovies topic, every event produced there is consumed by this stream
 
       final KStream<String, MoviePlayed> playEvents = builder.stream(
               PLAY_MOVIES_TOPIC, Consumed.with(Serdes.String(), moviePlayedSerder));
       
//  PlayedMovies has the region as key, and the object as value. Let’s map the content so the key is the movie id (to do the join) and leave the object as value
// Moreover, we do the join using the keys of the movies table (movieId) and the keys of the stream (we changed it to be the movieId too in the map method).
 
// Finally, the result is streamed to console
 
       playEvents
           .map((key, value) -> KeyValue.pair(value.id, value)) // Now key is the id field
           .join(moviesTable, (movieId, moviePlayedId) -> movieId, (moviePlayed, movie) -> movie)
           .print(Printed.toSysOut());
       return builder.build();
 
   }
}

MovieMoviePlayed POJO包含逻辑所需的属性。

Movie 对象是:

package org.acme;
 
public class Movie {
  
   public int id;
   public String name;
   public String director;
   public String genre;
 
   public Movie(int id, String name, String director, String genre) {
       this.id = id;
       this.name = name;
       this.director = director;
       this.genre = genre;
   }
}

MoviePlayed 对象是:

package org.acme;
 
public class MoviePlayed {
  
   public int id;
   public long duration;
 
   public MoviePlayed(int id, long duration) {
       this.id = id;
       this.duration = duration;
   }
 
}

在运行Kafka流应用程序之前的最后一步是配置一些参数,其中最重要的是quarkus.kafka-streams.topics 。这是一个在拓扑结构开始处理数据之前需要存在于Kafka集群中的主题列表,是一个前提条件。

打开src/main/resources/application.properties 文件,添加以下几行:

kafka-streams.cache.max.bytes.buffering=10240
kafka-streams.commit.interval.ms=1000
kafka-streams.metadata.max.age.ms=500
kafka-streams.auto.offset.reset=earliest
kafka-streams.metrics.recording.level=DEBUG
 
quarkus.kafka-streams.topics=playtimemovies,movies

现在,是测试流的时候了。让我们启动上一篇文章中开发的生产者。制作者的源代码可以在这里找到。

Quarkus KStreams与Quarkus DevServices集成。出于这个原因,我们不需要启动Kafka集群,也不需要配置它的位置,因为Quarkus Dev模式会处理一切。只要记得在你的电脑上有一个工作的容器运行时间,如Podman或任何其他符合OCI的工具。

在一个终端窗口中启动生产者服务:

cd movie-plays-producer
./mvnw compile quarkus:dev

2022-04-11 07:49:31,900 INFO  [org.acm.mov.MovieKafkaGenerator] (executor-thread-0) movie Cruella played for 287 minutes
2022-04-11 07:49:31,941 INFO  [io.quarkus] (Quarkus Main Thread) movie-plays-producer 1.0.0-SNAPSHOT on JVM (powered by Quarkus 2.7.3.Final) started in 4.256s.
2022-04-11 07:49:31,942 INFO  [io.quarkus] (Quarkus Main Thread) Profile dev activated. Live Coding activated.
2022-04-11 07:49:31,943 INFO  [io.quarkus] (Quarkus Main Thread) Installed features: [cdi, kafka-client, smallrye-context-propagation, smallrye-reactive-messaging, smallrye-reactive-messaging-kafka, vertx]
2022-04-11 07:49:32,399 INFO  [org.acm.mov.MovieKafkaGenerator] (executor-thread-0) movie Encanto played for 162 minutes
2022-04-11 07:49:32,899 INFO  [org.acm.mov.MovieKafkaGenerator] (executor-thread-0) movie The Hobbit played for 255 minutes
2022-04-11 07:49:33,404 INFO  [org.acm.mov.MovieKafkaGenerator] (executor-thread-0) movie Sing 2 played for 264 minutes
2022-04-11 07:49:33,902 INFO  [org.acm.mov.MovieKafkaGenerator] (executor-thread-0) movie Encanto played for 28 minutes
2022-04-11 07:49:34,402 INFO  [org.acm.mov.MovieKafkaGenerator] (executor-thread-0) movie Star Trek: First Contact played for 137 minutes
2022-04-11 07:49:34,903 INFO  [org.acm.mov.MovieKafkaGenerator] (executor-thread-0) movie Star Trek: First Contact played for 277 minutes
2022-04-11 07:49:35,402 INFO  [org.acm.mov.MovieKafkaGenerator] (executor-thread-0) movie The Hobbit played for 141 minutes

在另一个终端窗口中,启动我们现在开发的Kafka流代码:

./mvnw compile quarkus:dev

2022-04-11 07:54:59,321 INFO  [org.apa.kaf.str.pro.int.StreamTask] (movie-plays-kstreams-22c86daa-cd28-4956-9d0d-57b6b282a2ea-StreamThread-1) stream-thread [movie-plays-kstreams-22c86daa-cd28-4956-9d0d-57b6b282a2ea-StreamThread-1] task [1_0] Restored and ready to run
2022-04-11 07:54:59,322 INFO  [org.apa.kaf.str.pro.int.StreamThread] (movie-plays-kstreams-22c86daa-cd28-4956-9d0d-57b6b282a2ea-StreamThread-1) stream-thread [movie-plays-kstreams-22c86daa-cd28-4956-9d0d-57b6b282a2ea-StreamThread-1] Restoration took 74 ms for all tasks [1_0]
2022-04-11 07:54:59,322 INFO  [org.apa.kaf.str.pro.int.StreamThread] (movie-plays-kstreams-22c86daa-cd28-4956-9d0d-57b6b282a2ea-StreamThread-1) stream-thread [movie-plays-kstreams-22c86daa-cd28-4956-9d0d-57b6b282a2ea-StreamThread-1] State transition from PARTITIONS_ASSIGNED to RUNNING
2022-04-11 07:54:59,324 INFO  [org.apa.kaf.str.KafkaStreams] (movie-plays-kstreams-22c86daa-cd28-4956-9d0d-57b6b282a2ea-StreamThread-1) stream-client [movie-plays-kstreams-22c86daa-cd28-4956-9d0d-57b6b282a2ea] State transition from REBALANCING to RUNNING
[KSTREAM-LEFTJOIN-0000000005]: 4, Movie [director=Craig Gillespie, genre=Crime Comedy, id=4, name=Cruella]
[KSTREAM-LEFTJOIN-0000000005]: 2, Movie [director=Jonathan Frakes, genre=Space, id=2, name=Star Trek: First Contact]
[KSTREAM-LEFTJOIN-0000000005]: 4, Movie [director=Craig Gillespie, genre=Crime Comedy, id=4, name=Cruella]
[KSTREAM-LEFTJOIN-0000000005]: 1, Movie [director=Peter Jackson, genre=Fantasy, id=1, name=The Hobbit]
[KSTREAM-LEFTJOIN-0000000005]: 4, Movie [director=Craig Gillespie, genre=Crime Comedy, id=4, name=Cruella]
[KSTREAM-LEFTJOIN-0000000005]: 4, Movie [director=Craig Gillespie, genre=Crime Comedy, id=4, name=Cruella]
[KSTREAM-LEFTJOIN-0000000005]: 3, Movie [director=Jared Bush, genre=Animation, id=3, name=Encanto]
[KSTREAM-LEFTJOIN-0000000005]: 5, Movie [director=Garth Jennings, genre=Jukebox Musical Comedy, id=5, name=Sing 2]

在输出中打印的是一个事件(由连接产生),其中键是movieId ,值是movie 本身。我们现在的情况是,每当一个电影被停止,Kafka Stream就会处理它,并显示它的所有电影信息。

到目前为止,并不复杂,你可能会想,仅仅为了这个用例,不值得使用Kafka Streams。但是,让我们开始添加更多的要求,这样你就能看到它的强大之处。

与其在用户每次停止观看电影时生成一个事件,不如只对用户观看了10分钟以上的电影发送事件。

使用filter 方法按持续时间过滤电影:

playEvents
       .filter((region, event) -> event.duration >= 10) // filters by duration
       .map((key, value) -> KeyValue.pair(value.id, value))
       .join(moviesTable, (movieId, moviePlayedId) -> movieId, (moviePlayed, movie) -> movie)
        .print(Printed.toSysOut());

重新启动应用程序,你会发现持续时间小于或等于10分钟的电影不会被处理。

我们开始看到Kafka Streams有助于代码的清洁,但让我们增加最后的要求。我们感兴趣的不是每部电影的播放时间,而是每部电影的播放时间超过10分钟的次数。

到目前为止,事件的处理是无状态的,因为事件被接收、处理,并被发送到一个汇流处理器(可以是主题,也可以是控制台输出),但是为了计算一部电影被播放的次数,我们需要一些内存来记住一部电影被播放了多少次,并在任何用户再次观看超过10分钟时递增一次。事件的处理需要以一种有状态的方式进行。

我们首先需要创建一个Java类来存储电影名称和可视化的次数:

public class MoviePlayCount {
   public String name;
   public int count;
 
   public MoviePlayCount aggregate(String name) {
       this.name = name;
       this.count++;
 
       return this;
   }
 
   @Override
   public String toString() {
       return "MoviePlayCount [count=" + count + ", name=" + name + "]";
   }
  
}

这就是计数器类,但它仍然需要两样东西:

  • 一个存储这个类的实例的地方,这样它们就不会在每次事件被触发时被重置。
  • 一个在playtimemovies主题中每次事件被触发时调用aggregate 方法的逻辑。

对于第一个问题,我们需要使用 [KeyValueBytesStoreSupplier](https://kafka.apache.org/28/javadoc/org/apache/kafka/streams/state/KeyValueBytesStoreSupplier.html)接口。

public static final String COUNT_MOVIE_STORE = "countMovieStore";

KeyValueBytesStoreSupplier storeSupplier = Stores.persistentKeyValueStore(COUNT_MOVIE_STORE);

对于第二个问题,Kafka Streams有aggregate 方法来聚合结果。

在我们的用例中,电影播放时间超过10分钟的次数:

// MoviePlayCount might be serialized/deserialized too
final ObjectMapperSerde<MoviePlayCount> moviePlayCountSerder = new ObjectMapperSerde<>(MoviePlayCount.class);

// This is the join call seen before, where key is the movie id and value is the movie
.join(moviesTable, (movieId, moviePlayedId) -> movieId, (moviePlayed, movie) -> movie)
// Group events per key, in this case movie id
.groupByKey(Grouped.with(Serdes.Integer(), movieSerder))
 // Aggregate method gets the MoviePlayCount object if already created (if not it creates it) and calls its aggregate method to increment by one the viwer counter
 .aggregate(MoviePlayCount::new,
             (movieId, movie, moviePlayCounter) -> moviePlayCounter.aggregate(movie.name),
              Materialized.<Integer, MoviePlayCount> as(storeSupplier)
                  .withKeySerde(Serdes.Integer())
                  .withValueSerde(moviePlayCountSerder)
             )

重新启动应用程序,一部电影的播放次数会显示在控制台。

提示:要重启应用程序,在终端按字母 "s",它就会自动重启。

重启应用程序后,终端开始显示每部电影的统计资料:

[KTABLE-TOSTREAM-0000000011]: 4, MoviePlayCount [count=13, name=Cruella]
[KTABLE-TOSTREAM-0000000011]: 3, MoviePlayCount [count=11, name=Encanto]
[KTABLE-TOSTREAM-0000000011]: 5, MoviePlayCount [count=14, name=Sing 2]
[KTABLE-TOSTREAM-0000000011]: 2, MoviePlayCount [count=15, name=Star Trek: First Contact]
[KTABLE-TOSTREAM-0000000011]: 1, MoviePlayCount [count=16, name=The Hobbit]
[KTABLE-TOSTREAM-0000000011]: 2, MoviePlayCount [count=16, name=Star Trek: First Contact]
[KTABLE-TOSTREAM-0000000011]: 3, MoviePlayCount [count=12, name=Encanto]
[KTABLE-TOSTREAM-0000000011]: 2, MoviePlayCount [count=17, name=Star Trek: First Contact]
[KTABLE-TOSTREAM-0000000011]: 5, MoviePlayCount [count=15, name=Sing 2]
[KTABLE-TOSTREAM-0000000011]: 4, MoviePlayCount [count=14, name=Cruella]
[KTABLE-TOSTREAM-0000000011]: 1, MoviePlayCount [count=17, name=The Hobbit]
[KTABLE-TOSTREAM-0000000011]: 4, MoviePlayCount [count=15, name=Cruella]
[KTABLE-TOSTREAM-0000000011]: 4, MoviePlayCount [count=16, name=Cruella]

互动查询

汇总的结果被流向控制台,因为我们把汇出的处理器设置为System.out流:

.toStream()
.print(Printed.toSysOut());

但你也可以把结果流发送到Kafka主题。

.to("counter_movies",                      Produced.with(Serdes.Integer(), moviePlayCountSerder)
);

但是,如果你对每次发送新事件时的反应不感兴趣,而只是查询某个特定的电影在那个时候被播放了多少次,会发生什么?

Kafka Streams交互式查询允许你直接查询底层状态存储中与给定键相关的值。

首先,让我们创建一个名字为MoviePlayCountData 的类,用于存储查询的结果。我们这样做是为了将Kafka Streams中使用的类与应用程序其他部分使用的类解耦:

public class MoviePlayCountData {
 
   private String name;
   private int count;
  
   public MoviePlayCountData(String name, int count) {
       this.name = name;
       this.count = count;
   }
 
   public int getCount() {
       return count;
   }
 
   public String getName() {
       return name;
   }
 
}

现在创建一个名为InteractiveQueries 的类来实现对状态存储的访问(KeyValueBytesStoreSupplier),并通过其id 查询一部电影被播放的次数:

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
 
import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.errors.InvalidStateStoreException;
import org.apache.kafka.streams.state.QueryableStoreTypes;
import org.apache.kafka.streams.state.ReadOnlyKeyValueStore;
 
import static org.apache.kafka.streams.StoreQueryParameters.fromNameAndType;
 
import java.util.Optional;
 
@ApplicationScoped
public class InteractiveQueries {
  
   @Inject
   KafkaStreams streams;
 
  
   public Optional<MoviePlayCountData> getMoviePlayCountData(int id) {
       // gets the state store and get the movie count by movie id
       MoviePlayCount moviePlayCount = getMoviesPlayCount().get(id);
       // If there is a result
       if (moviePlayCount != null) {
           // Wrap the result into MoviePlayCountData
           return Optional.of(new MoviePlayCountData(moviePlayCount.name, moviePlayCount.count));
       } else {
           return Optional.empty();
       }
   }
 
 
   // Gets the state store
   private ReadOnlyKeyValueStore<Integer, MoviePlayCount> getMoviesPlayCount() {
       while (true) {
           try {
               return streams.store(fromNameAndType(TopologyProducer.COUNT_MOVIE_STORE, QueryableStoreTypes.keyValueStore()));
           } catch (InvalidStateStoreException e) {
               // ignore, store not ready yet
           }
       }
   }
 
}

我们现在可以添加一个简单的REST端点来运行这个查询:

import java.util.Optional;
 
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
 
@Path("/movie")
public class MovieCountResource {
 
   // Injects the previous class to make queries
   @Inject
   InteractiveQueries interactiveQueries;
 
   @GET
   @Path("/data/{id}")
   public Response movieCountData(@PathParam("id") int id) {
       Optional<MoviePlayCountData> moviePlayCountData = interactiveQueries.getMoviePlayCountData(id);
 
       // Depending on the result returns the value or a 404
       if (moviePlayCountData.isPresent()) {
           return Response.ok(moviePlayCountData.get()).build();
       } else {
           return Response.status(Status.NOT_FOUND.getStatusCode(),
                   "No data found for movie " + id).build();
       }
 
   }
}

实现的Kafka流的模式见下图:

扩大规模

Kafka流的应用可以扩展,因此流是通过多个实例分布的。在这种情况下,每个实例都包含聚合结果的一个子集,所以要想获得总的聚合结果,需要通过将REST API请求重定向到另一个实例,直接从该实例获取数据。

Kafka Streams提供了一个API,可以知道请求的数据是在本地Kafka Streams存储中还是在另一个主机中。

虽然这个过程并不复杂,但已经超出了本文的讨论范围。

结论

到目前为止,你已经看到,将Quarkus应用程序连接到Apache Kafka并开始生产和消费主题中的消息/事件是很容易的。此外,你还看到Kafka Streams让我们不仅可以消费Kafka消息,还可以实时处理它们,应用转换、过滤,例如以同步的方式消费结果数据。 这是一项强大的技术,当需要处理的数据不断变化时,它可以轻松扩展,提供实时体验。

但是我们还没有解决这个架构的最后一个问题。通常情况下,数据并不是存储在一个地方。电影信息可能存储在一个关系型数据库中,而播放的电影信息则存储在一个Kafka主题中。那么你如何保持这两个地方的信息更新,以便Kafka流能够正确地连接数据?

这里有一个缺失的部分,一个叫做Debezium的项目可以帮助我们解决这个问题。我们将用一整篇文章来介绍Debezium和Quarkus。敬请期待。