Kafka-快速启动指南-全-

Kafka 快速启动指南(全)

原文:zh.annas-archive.org/md5/9ebfdffd70d52525c1c1c45ea0cd178a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自 2011 年以来,Kafka 在增长方面呈爆炸式增长。超过三分之一的《财富》500 强公司使用 Apache Kafka。这些公司包括旅游公司、银行、保险公司和电信公司。

Uber、Twitter、Netflix、Spotify、Blizzard、LinkedIn、Spotify 和 PayPal 每天使用 Apache Kafka 处理他们的消息。

今天,Apache Kafka 被用于收集数据、进行实时数据分析以及执行实时数据流。Kafka 还被用于向复杂事件处理CEP)架构提供事件,部署在微服务架构中,并在物联网IoT)系统中实现。

在流处理领域,有几个 Kafka Streams 的竞争对手,包括 Apache Spark、Apache Flink、Akka Streams、Apache Pulsar 和 Apache Beam。它们都在竞争以超越 Kafka。然而,Apache Kafka 在所有这些方面都有一个关键优势:其易用性。Kafka 易于实现和维护,其学习曲线并不陡峭。

本书是一本实用的快速入门指南。它专注于展示实际示例,不涉及 Kafka 架构的理论解释或讨论。本书是实际操作食谱的汇编,为实施 Apache Kafka 的人提供日常问题的解决方案。

本书面向的对象

本书面向数据工程师、软件开发人员和数据架构师,他们寻找快速上手 Kafka 的指南。

本指南是关于编程的;它是为那些对 Apache Kafka 没有先验知识的人提供的入门介绍。

所有示例均使用 Java 8 编写;对 Java 8 的了解是遵循本指南的唯一要求。

本书涵盖的内容

第一章,配置 Kafka,解释了开始使用 Apache Kafka 的基本知识。它讨论了如何安装、配置和运行 Kafka。它还讨论了如何使用 Kafka 代理和主题进行基本操作。

第二章,消息验证,探讨了如何为您的企业服务总线编程数据验证,包括如何从输入流中过滤消息。

第三章,消息增强,探讨了消息增强,这是企业服务总线的重要任务之一。消息增强是将额外信息纳入您流的消息的过程。

第四章,序列化,讨论了如何构建序列化和反序列化器,用于以二进制、原始字符串、JSON 或 AVRO 格式编写、读取或转换消息。

第五章,模式注册表,涵盖了如何使用 Kafka 模式注册表验证、序列化、反序列化和保留消息版本的历史记录。

第六章,Kafka Streams,解释了如何获取关于一组消息(换句话说,消息流)的信息,以及如何使用 Kafka Streams 获取其他信息,例如与消息的聚合和组合有关的信息。

第七章,KSQL,讨论了如何使用 SQL 在 Kafka Streams 上不写一行代码来操作事件流。

第八章,Kafka Connect,讨论了其他快速数据处理工具以及如何与 Apache Kafka 结合构建数据处理管道。本章涵盖了 Apache Spark 和 Apache Beam 等工具。

为了充分利用本书

读者应具备使用 Java 8 进行编程的一些经验。

执行本书中食谱所需的最小配置是一个 Intel ® Core i3 处理器、4 GB 的 RAM 和 128 GB 的磁盘空间。推荐使用 Linux 或 macOS,因为 Windows 不支持完全。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com上登录或注册。

  2. 选择 SUPPORT 标签。

  3. 点击代码下载与勘误。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载后,请确保使用最新版本解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Apache-Kafka-Quick-Start-Guide。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们!

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“--topic参数设置主题名称;在这种情况下,amazingTopic。”

代码块设置如下:

{
   "event": "CUSTOMER_CONSULTS_ETHPRICE",
   "customer": {
         "id": "14862768",
         "name": "Snowden, Edward",
         "ipAddress": "95.31.18.111"
   },
   "currency": {
         "name": "ethereum",
         "price": "RUB"
   },
   "timestamp": "2018-09-28T09:09:09Z"
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

dependencies {
    compile group: 'org.apache.kafka', name: 'kafka_2.12', version:                                                             
                                                          '2.0.0'
    compile group: 'com.maxmind.geoip', name: 'geoip-api', version: 
                                                          '1.3.1'
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: '2.9.7'
}

任何命令行输入或输出都如下所示:

> <confluent-path>/bin/kafka-topics.sh --list --ZooKeeper localhost:2181

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“为了区分它们,t1上的事件有一条条纹,t2上的事件有两条条纹,t3上的事件有三条条纹。”

警告或重要提示如下所示。

技巧和窍门如下所示。

联系我们

读者反馈始终欢迎。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com将邮件发送给我们。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com.

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需了解 Packt 的更多信息,请访问packt.com.

第一章:配置 Kafka

本章描述了 Kafka 是什么以及与该技术相关的概念:代理、主题、生产者和消费者。它还讨论了如何从命令行构建简单的生产者和消费者,以及如何安装 Confluent 平台。本章中的信息对于以下章节是基本的。

在本章中,我们将涵盖以下主题:

  • Kafka 简述

  • 安装 Kafka(Linux 和 macOS)

  • 安装 Confluent 平台

  • 运行 Kafka

  • 运行 Confluent 平台

  • 运行 Kafka 代理

  • 运行 Kafka 主题

  • 命令行消息生产者

  • 命令行消息消费者

  • 使用 kafkacat

Kafka 简述

Apache Kafka 是一个开源的流平台。如果你正在阅读这本书,也许你已经知道 Kafka 在横向扩展方面表现非常出色,而且不会牺牲速度和效率。

Kafka 的核心是用 Scala 编写的,Kafka Streams 和 KSQL 是用 Java 编写的。Kafka 服务器可以在多个操作系统上运行:Unix、Linux、macOS,甚至 Windows。由于它通常在生产环境中运行在 Linux 服务器上,本书中的示例是为 Linux 环境设计的。本书中的示例还考虑了 bash 环境的使用。

本章解释了如何安装、配置和运行 Kafka。由于这是一个快速入门指南,它不涵盖 Kafka 的理论细节。目前,提及以下三点是合适的:

  • Kafka 是一个 服务总线: 为了连接异构应用程序,我们需要实现一个消息发布机制,以便在它们之间发送和接收消息。消息路由器被称为消息代理。Kafka 是一个消息代理,是一种快速处理客户端之间消息路由的解决方案。

  • Kafka 架构有两个指令: 第一个是不要阻塞生产者(为了处理背压)。第二个是隔离生产者和消费者。生产者不应该知道他们的消费者是谁,因此 Kafka 遵循愚笨代理和智能客户端模型。

  • Kafka 是一个实时消息系统: 此外,Kafka 是一个具有发布-订阅模型的软件解决方案:开源、分布式、分区、复制和基于提交日志。

Apache Kafka 中有一些概念和术语:

  • 集群: 这是一组 Kafka 代理。

  • Zookeeper: 这是一个集群协调器——一个包含 Apache 生态系统不同服务的工具。

  • 代理: 这是一个 Kafka 服务器,也是 Kafka 服务器进程本身。

  • 主题: 这是一个队列(具有日志分区);一个代理可以运行多个主题。

  • 偏移量: 这是每个消息的标识符。

  • 分区: 这是一个不可变且有序的记录序列,持续追加到一个结构化的提交日志中。

  • 生产者: 这是一个将数据发布到主题的程序。

  • 消费者: 这是一个从主题中处理数据的程序。

  • 保留期: 这是保留消息以供消费的时间。

在 Kafka 中,有三种类型的集群:

  • 单节点-单代理

  • 单节点-多代理

  • 多节点-多代理

在 Kafka 中,有三种(仅三种)发送消息的方式:

  • 永不重新投递:消息可能会丢失,因为一旦投递,它们就不会再次发送。

  • 可能重新投递:消息永远不会丢失,因为如果未收到,消息可以再次发送。

  • 单次投递:消息正好投递一次。这是最困难的投递形式;由于消息只发送一次且不会重新投递,这意味着不会有任何消息丢失。

消息日志可以以两种方式压缩:

  • 粗粒度:按时间压缩的日志

  • 细粒度:按消息压缩的日志

Kafka 安装

有三种方式可以安装 Kafka 环境:

  • 下载可执行文件

  • 使用 brew(在 macOS 上)或 yum(在 Linux 上)

  • 安装 Confluent 平台

对于所有三种方式,第一步是安装 Java;我们需要 Java 8。从 Oracle 的网站下载并安装最新的 JDK 8:

www.oracle.com/technetwork/java/javase/downloads/index.html

在撰写本文时,最新的 Java 8 JDK 版本是 8u191。

对于 Linux 用户:

  1. 按以下步骤更改文件模式为可执行:
 > chmod +x jdk-8u191-linux-x64.rpm
  1. 前往您想要安装 Java 的目录:
 > cd <directory path>
  1. 使用以下命令运行 rpm 安装程序:
 > rpm -ivh jdk-8u191-linux-x64.rpm
  1. JAVA_HOME 变量添加到您的环境中。以下命令将 JAVA_HOME 环境变量写入 /etc/profile 文件:
 > echo "export JAVA_HOME=/usr/java/jdk1.8.0_191" >> /etc/profile
  1. 按以下方式验证 Java 安装:
 > java -version
      java version "1.8.0_191"
      Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
      Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

在撰写本文时,最新的 Scala 版本是 2.12.6。要在 Linux 上安装 Scala,请执行以下步骤:

  1. www.scala-lang.org/download 下载最新的 Scala 二进制文件

  2. 按以下方式提取下载的文件,scala-2.12.6.tgz

 > tar xzf scala-2.12.6.tgz
  1. 按以下方式将 SCALA_HOME 变量添加到您的环境中:
 > export SCALA_HOME=/opt/scala
  1. 按以下方式将 Scala 的 bin 目录添加到您的 PATH 环境变量中:
 > export PATH=$PATH:$SCALA_HOME/bin
  1. 要验证 Scala 安装,请执行以下操作:
 >  scala -version
      Scala code runner version 2.12.6 -- Copyright 2002-2018,
      LAMP/EPFL and Lightbend, Inc. 

要在您的机器上安装 Kafka,请确保您至少有 4 GB 的 RAM,并且安装目录对于 macOS 用户将是 /usr/local/kafka/,对于 Linux 用户将是 /opt/kafka/。根据您的操作系统创建这些目录。

在 Linux 上安装 Kafka

打开 Apache Kafka 下载页面,kafka.apache.org/downloads,如图 图 1.1 所示:

图片

图 1.1:Apache Kafka 下载页面

在撰写本文时,当前的 Apache Kafka 版本是 2.0.0 作为稳定版本。请记住,自 0.8.x 版本以来,Kafka 不向下兼容。因此,我们不能用低于 0.8 的版本替换此版本。一旦下载了最新可用的版本,让我们继续进行安装。

请记住,对于 macOS 用户,将目录 /opt/ 替换为 /usr/local

按照以下步骤在 Linux 上安装 Kafka:

  1. 按照以下步骤在 /opt/ 目录中解压下载的文件 kafka_2.11-2.0.0.tgz
 > tar xzf kafka_2.11-2.0.0.tgz
  1. 按照以下步骤创建 KAFKA_HOME 环境变量:
 > export KAFKA_HOME=/opt/kafka_2.11-2.0.0
  1. 按照以下步骤将 Kafka 的 bin 目录添加到 PATH 变量中:
 > export PATH=$PATH:$KAFKA_HOME/bin

现在 Java、Scala 和 Kafka 已安装。

要从命令行执行所有前面的步骤,macOS 用户有一个强大的工具叫做 brew(Linux 中的等效工具是 yum)。

在 macOS 上安装 Kafka

在 macOS 上从命令行安装(必须已安装 brew),执行以下步骤:

  1. 要使用 brew 安装 sbt(Scala 构建工具),执行以下命令:
 > brew install sbt

如果已经在环境中安装(之前已下载),运行以下命令进行升级:

 > brew upgrade sbt

输出类似于 图 1.2 中所示:

图 1.2:Scala 构建工具安装输出

  1. 要使用 brew 安装 Scala,执行以下命令:
 > brew install scala

如果已经在环境中安装(之前已下载),要升级它,请运行以下命令:

 > brew upgrade scala

输出类似于 图 1.3 中所示:

图 1.3:Scala 安装输出

  1. 使用 brew 安装 Kafka(它也会安装 Zookeeper),请执行以下操作:
 > brew install kafka

如果已经拥有它(以前已下载),按照以下方式升级:

 > brew upgrade kafka

输出类似于 图 1.4 中所示:

图 1.4:Kafka 安装输出

访问 brew.sh/ 了解更多关于 brew 的信息。

Confluent 平台安装

安装 Kafka 的第三种方式是通过 Confluent 平台。在本书的其余部分,我们将使用 Confluent 平台的开放源代码版本。

Confluent 平台是一个包含以下组件的集成平台:

  • Apache Kafka

  • REST 代理

  • Kafka Connect API

  • 模式注册表

  • Kafka Streams API

  • 预构建连接器

  • 非 Java 客户端

  • KSQL

如果读者注意到,本书中几乎每个组件都有自己的章节。

商业授权的 Confluent 平台除了包含开源版本的所有组件外,还包括以下内容:

  • Confluent 控制中心CCC

  • Kafka 操作员(用于 Kubernetes)

  • JMS 客户端

  • 复制器

  • MQTT 代理

  • 自动数据平衡器

  • 安全功能

重要的一点是,非开源版本组件的培训超出了本书的范围。

Confluent 平台也以 Docker 镜像的形式提供,但在这里我们将本地安装它。

打开 Confluent 平台下载页面:www.confluent.io/download/

在撰写本文时,Confluent 平台的当前版本是 5.0.0,作为稳定版本。请记住,由于 Kafka 核心运行在 Scala 上,因此有两个版本:Scala 2.11 和 Scala 2.12。

我们可以从桌面目录运行 Confluent 平台,但根据本书的约定,让我们为 Linux 用户使用 /opt/,为 macOS 用户使用 /usr/local

要安装 Confluent 平台,将下载的文件 confluent-5.0.0-2.11.tar.gz 解压到目录中,如下所示:

> tar xzf confluent-5.0.0-2.11.tar.gz

运行 Kafka

根据我们是直接安装还是通过 Confluent 平台安装 Kafka,有两种运行 Kafka 的方法。

如果我们直接安装,运行 Kafka 的步骤如下。

对于 macOS 用户,如果您使用 brew 安装,您的路径可能不同。检查 brew install kafka 命令的输出,以获取可以启动 Zookeeper 和 Kafka 的确切命令。

前往 Kafka 安装目录(macOS 用户为 /usr/local/kafka,Linux 用户为 /opt/kafka/),例如:

> cd /usr/local/kafka

首先,我们需要启动 Zookeeper(Kafka 与 Zookeeper 的依赖关系目前很强,并将保持不变)。请输入以下命令:

> ./bin/zookeeper-server-start.sh ../config/zookeper.properties

ZooKeeper JMX enabled by default

Using config: /usr/local/etc/zookeeper/zoo.cfg

Starting zookeeper ... STARTED

要检查 Zookeeper 是否正在运行,使用以下命令在 9093 端口(默认端口)上运行 lsof 命令:

> lsof -i :9093

COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME

java 12529 admin 406u IPv6 0xc41a24baa4fedb11 0t0 TCP *:9093 (LISTEN)

现在,通过访问 /usr/local/kafka/(macOS 用户)和 /opt/kafka/(Linux 用户)来运行安装的 Kafka 服务器:

> ./bin/kafka-server-start.sh ./config/server.properties

现在您的机器上正在运行一个 Apache Kafka 代理。

请记住,在启动 Kafka 之前,Zookeeper 必须在机器上运行。如果您不希望每次需要运行 Kafka 时都手动启动 Zookeeper,请将其安装为操作系统的自动启动服务。

运行 Confluent 平台

前往 Confluent 平台安装目录(macOS 用户为 /usr/local/kafka/,Linux 用户为 /opt/kafka/),并输入以下命令:

> cd /usr/local/confluent-5.0.0

要启动 Confluent 平台,请运行以下命令:

> bin/confluent start

此命令行界面仅适用于开发,不适用于生产:

docs.confluent.io/current/cli/index.html

输出类似于以下代码片段:

Using CONFLUENT_CURRENT: /var/folders/nc/4jrpd1w5563crr_np997zp980000gn/T/confluent.q3uxpyAt

Starting zookeeper
zookeeper is [UP]
Starting kafka
kafka is [UP]
Starting schema-registry
schema-registry is [UP]
Starting kafka-rest
kafka-rest is [UP]
Starting connect
connect is [UP]
Starting ksql-server
ksql-server is [UP]
Starting control-center
control-center is [UP]

如命令输出所示,Confluent 平台会按以下顺序自动启动:Zookeeper、Kafka、Schema Registry、REST 代理、Kafka Connect、KSQL 和 Confluent 控制中心。

要访问您本地运行的 Confluent 控制中心,请访问 http://localhost:9021,如图 1.5 所示:

图片

图 1.5:Confluent 控制中心主页面

Confluent 平台还有其他命令。

要检查所有服务或特定服务及其依赖项的状态,请输入以下命令:

> bin/confluent status

要停止所有服务或停止特定服务及其依赖的服务,请输入以下命令:

> bin/confluent stop

要删除当前 Confluent 平台的数据和日志,请输入以下命令:

> bin/confluent destroy

运行 Kafka 代理

服务器背后的真正艺术在于其配置。在本节中,我们将探讨如何处理 Kafka 代理在独立模式下的基本配置。由于我们目前在学习,因此我们将不会回顾集群配置。

如我们所料,有两种类型的配置:独立和集群。当在集群模式下运行并具有复制功能时,Kafka 的真实力量才得以释放,并且所有主题都正确分区。

集群模式有两个主要优势:并行性和冗余性。并行性是指集群成员之间可以同时运行任务的能力。冗余性确保当 Kafka 节点故障时,集群仍然安全且可以从其他运行节点访问。

本节展示了如何在我们的本地机器上配置具有多个节点的集群,尽管在实践中,最好有多个机器,多个节点共享集群总是更好的选择。

进入 Confluent 平台安装目录,从现在起称为<confluent-path>

如本章开头所述,代理是一个服务器实例。服务器(或代理)实际上是在操作系统上运行的一个进程,它根据其配置文件启动。

Confluent 的人们友好地为我们提供了一个标准代理配置的模板。这个文件名为server.properties,位于 Kafka 安装目录的config子目录中:

  1. <confluent-path>内部,创建一个名为mark的目录。

  2. 对于我们想要运行的每个 Kafka 代理(服务器),我们需要复制配置文件模板并相应地重命名它。在这个例子中,我们的集群将被命名为mark

> cp config/server.properties <confluent-path>/mark/mark-1.properties
> cp config/server.properties <confluent-path>/mark/mark-2.properties
  1. 根据需要修改每个属性文件。如果文件名为mark-1,则broker.id应为1。然后,指定服务器将运行的端口;对于mark-1推荐使用9093,对于mark-2推荐使用9094。请注意,端口号属性在模板中未设置,因此需要添加该行。最后,指定 Kafka 日志的位置(Kafka 日志是存储所有 Kafka 代理操作的特定存档);在这种情况下,我们使用/tmp目录。在这里,常见的问题是写权限问题。不要忘记给在日志目录中执行这些进程的用户授予写和执行权限,如下例所示:
  • mark-1.properties中设置以下内容:
 broker.id=1
 port=9093
 log.dirs=/tmp/mark-1-logs
  • mark-2.properties中设置以下内容:
 broker.id=2
 port=9094
 log.dirs=/tmp/mark-2-logs
  1. 使用kafka-server-start命令启动 Kafka 代理,并将相应的配置文件作为参数传递。不要忘记 Confluent 平台必须已经运行,并且端口不应被其他进程使用。以下是如何启动 Kafka 代理:
 > <confluent-path>/bin/kafka-server-start <confluent-
      path>/mark/mark-1.properties &

在另一个命令行窗口中,运行以下命令:

 > <confluent-path>/bin/kafka-server-start <confluent-
      path>/mark/mark-2.properties &

不要忘记,尾随的&是为了指定你想要命令行返回。如果你想查看代理输出,建议在每个自己的命令行窗口中单独运行每个命令。

记住属性文件包含服务器配置,而位于config目录中的server.properties文件只是一个模板。

现在有mark-1mark-2两个代理在同一台机器上同一集群中运行。

记住,没有愚蠢的问题,如下面的例子所示:

Q:每个代理如何知道它属于哪个集群?

A:代理知道它们属于同一个集群,因为在配置中,它们都指向同一个 Zookeeper 集群。

Q:同一集群内的每个代理与其他代理有何不同?

A:每个代理在集群内部通过broker.id属性中指定的名称来标识。

Q:如果没有指定端口号会发生什么?

A:如果没有指定端口属性,Zookeeper 将分配相同的端口号,并将覆盖数据。

Q:如果没有指定日志目录会发生什么?

A:如果没有指定log.dir,所有代理都将写入相同的默认log.dir。如果计划在不同的机器上运行代理,则可能不会指定端口和log.dir属性(因为它们在相同的端口和日志文件上运行,但在不同的机器上)。

Q:我如何检查我想要启动代理的端口上是否没有正在运行进程?

A:如前一小节所示,有一个有用的命令可以查看在特定端口上运行什么进程,在这种情况下是9093端口:

> lsof -i :9093

上一条命令的输出类似于以下内容:

COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME

java 12529 admin 406u IPv6 0xc41a24baa4fedb11 0t0 TCP *:9093 (LISTEN)

您的机会:在启动 Kafka 代理之前尝试运行此命令,并在启动后运行它以查看变化。此外,尝试在一个正在使用的端口上启动代理,以查看它如何失败。

好的,如果我想要我的集群在多台机器上运行怎么办?

要在不同的机器上运行 Kafka 节点但属于同一集群,请调整配置文件中的 Zookeeper 连接字符串;其默认值如下:

zookeeper.connect=localhost:2181

记住,机器必须能够通过 DNS 找到彼此,并且它们之间没有网络安全限制。

Zookeeper 连接的默认值只有在你在同一台机器上运行 Kafka 代理时才是正确的。根据架构的不同,可能需要决定是否会在同一台 Zookeeper 机器上运行代理。

要指定 Zookeeper 可能在其他机器上运行,请执行以下操作:

zookeeper.connect=localhost:2181, 192.168.0.2:2183, 192.168.0.3:2182

上一行指定 Zookeeper 在本地主机机器上的端口2181运行,在 IP 地址为192.168.0.2的机器上的端口2183运行,以及在 IP 地址为192.168.0.3的机器上的端口2182运行。Zookeeper 的默认端口是2181,所以通常它在那里运行。

您的机会:作为一个练习,尝试使用关于 Zookeeper 集群的错误信息启动代理。此外,使用lsof命令尝试在一个正在使用的端口上启动 Zookeeper。

如果您对配置有疑问,或者不清楚要更改哪些值,以下server.properties模板(以及 Kafka 项目的所有内容)都是开源的:

github.com/apache/kafka/blob/trunk/config/server.properties

运行 Kafka 主题

代理内部的力量是主题,即其中的队列。现在我们有两个代理正在运行,让我们在它们上创建一个 Kafka 主题。

Kafka,就像几乎所有的现代基础设施项目一样,有三种构建事物的方式:通过命令行、通过编程和通过 Web 控制台(在这种情况下是 Confluent 控制中心)。Kafka 代理的管理(创建、修改和销毁)可以通过大多数现代编程语言编写的程序来完成。如果该语言不受支持,则可以通过 Kafka REST API 进行管理。上一节展示了如何使用命令行构建代理。在后面的章节中,我们将看到如何通过编程来完成这个过程。

是否可以通过编程仅管理(创建、修改或销毁)代理?不,我们也可以管理主题。主题也可以通过命令行创建。Kafka 有预构建的实用工具来管理代理,就像我们之前看到的,以及管理主题,就像我们接下来将要看到的。

要在我们的运行集群中创建名为amazingTopic的主题,请使用以下命令:

> <confluent-path>/bin/kafka-topics --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic amazingTopic

输出应该如下所示:

Created topic amazingTopic

在这里,使用的是kafka-topics命令。通过--create参数指定我们想要创建一个新的主题。--topic参数设置主题的名称,在这种情况下,为amazingTopic

你还记得并行性和冗余这两个术语吗?嗯,--partitions参数控制并行性,而--replication-factor参数控制冗余。

--replication-factor参数是基本的,因为它指定了主题将在集群中的多少个服务器上进行复制(例如,运行)。另一方面,一个代理可以只运行一个副本。

显然,如果指定的服务器数量大于集群中运行的服务器数量,将会导致错误(你不相信我?在你的环境中试一试)。错误将类似于以下内容:

Error while executing topic command: replication factor: 3 larger than available brokers: 2

[2018-09-01 07:13:31,350] ERROR org.apache.kafka.common.errors.InvalidReplicationFactorException: replication factor: 3 larger than available brokers: 2

(kafka.admin.TopicCommand$)

要考虑的是,代理应该正在运行(不要害羞,在你的环境中测试所有这些理论)。

--partitions参数,正如其名称所暗示的,说明了主题将有多少个分区。这个数字决定了消费者侧可以实现的并行性。当进行集群微调时,此参数非常重要。

最后,正如预期的那样,--zookeeper参数指示 Zookeeper 集群正在运行的位置。

当创建一个主题时,代理日志中的输出类似于以下内容:

[2018-09-01 07:05:53,910] INFO [ReplicaFetcherManager on broker 1] Removed fetcher for partitions amazingTopic-0 (kafka.server.ReplicaFetcherManager)

[2018-09-01 07:05:53,950] INFO Completed load of log amazingTopic-0 with 1 log segments and log end offset 0 in 21 ms (kafka.log.Log)

简而言之,这条消息看起来就像在我们的集群中诞生了一个新的主题。

我如何检查我的新而闪亮的主题?通过使用相同的命令:kafka-topics

除了--create参数之外,还有更多的参数。要检查主题的状态,请使用带有--list参数的kafka-topics命令,如下所示:

> <confluent-path>/bin/kafka-topics.sh --list --zookeeper localhost:2181

我们知道,输出是主题列表,如下所示:

amazingTopic

此命令返回集群中所有运行主题的名称列表。

如何获取主题的详细信息?使用相同的命令:kafka-topics

对于特定主题,使用--describe参数运行kafka-topics命令,如下所示:

> <confluent-path>/bin/kafka-topics --describe --zookeeper localhost:2181 --topic amazingTopic

命令输出如下所示:

Topic:amazingTopic PartitionCount:1 ReplicationFactor:1 Configs: Topic: amazingTopic Partition: 0 Leader: 1 Replicas: 1 Isr: 1

这里是输出简要说明:

  • PartitionCount:主题上的分区数量(并行性)

  • ReplicationFactor:主题上的副本数量(冗余)

  • Leader:负责读取和写入给定分区操作的节点

  • Replicas:复制此主题数据的代理列表;其中一些甚至可能是已死亡的

  • Isr:当前同步副本的节点列表

让我们创建一个具有多个副本的主题(例如,我们将在集群中运行更多代理);我们输入以下内容:

> <confluent-path>/bin/kafka-topics --create --zookeeper localhost:2181 --replication-factor 2 --partitions 1 --topic redundantTopic

输出如下所示:

Created topic redundantTopic

现在,使用--describe参数调用kafka-topics命令来检查主题详情,如下所示:

> <confluent-path>/bin/kafka-topics --describe --zookeeper localhost:2181 --topic redundantTopic

Topic:redundantTopic PartitionCount:1 ReplicationFactor:2 Configs:

Topic: redundantTopic Partition: 0 Leader: 1 Replicas: 1,2 Isr: 1,2

如您所见,ReplicasIsr是相同的列表;我们推断所有节点都是同步的。

轮到你了:使用kafka-topics命令进行实验,尝试在已死亡的代理上创建复制主题,并查看输出。然后,在运行的服务器上创建主题,然后杀死它们以查看结果。输出是否如您预期的那样?

如前所述,所有这些通过命令行执行的命令都可以通过编程执行或通过 Confluent Control Center 网络控制台执行。

命令行消息生产者

Kafka 还有一个通过命令行发送消息的命令;输入可以是文本文件或控制台标准输入。输入的每一行都作为单个消息发送到集群。

对于本节,需要执行前面的步骤。Kafka 代理必须处于运行状态,并且在其中创建了一个主题。

在一个新的命令行窗口中,运行以下命令,然后输入要发送到服务器的消息行:

> <confluent-path>/bin/kafka-console-producer --broker-list localhost:9093 --topic amazingTopic 
Fool me once shame on you
Fool me twice shame on me

这些行将两条消息推送到运行在本机集群9093端口的amazingTopic

此命令也是检查具有特定主题的代理是否按预期运行的最简单方式。

正如我们所见,kafka-console-producer命令接收以下参数:

  • --broker-list:此指定以逗号分隔的列表形式指定的 Zookeeper 服务器,格式为,hostname:port。

  • --topic:此参数后跟目标主题的名称。

  • --sync:此指定消息是否应同步发送。

  • --compression-codec:此指定用于生产消息的压缩编解码器。可能的选项是:nonegzipsnappylz4。如果未指定,则默认为 gzip。

  • --batch-size:如果消息不是同步发送,但消息大小以单个批次发送,则此值以字节为单位指定。

  • --message-send-max-retries: 由于代理可能会失败接收消息,此参数指定了生产者在放弃并丢弃消息之前重试的次数。此数字必须是一个正整数。

  • --retry-backoff-ms: 在失败的情况下,节点领导者选举可能需要一些时间。此参数是在生产者在选举后重试之前等待的时间。数字是毫秒数。

  • --timeout: 如果生产者在异步模式下运行且设置了此参数,则表示消息在队列中等待足够大的批次大小时的最大时间。此值以毫秒表示。

  • --queue-size: 如果生产者在异步模式下运行且设置了此参数,则它给出消息队列的最大容量,等待足够大的批次大小。

在服务器微调的情况下,batch-sizemessage-send-max-retriesretry-backoff-ms非常重要;考虑这些参数以实现所需的行为。

如果您不想输入消息,命令可以接收一个文件,其中每行被视为一条消息,如下例所示:

<confluent-path>/bin/kafka-console-producer --broker-list localhost:9093 –topic amazingTopic < aLotOfWordsToTell.txt

命令行消息消费者

最后一步是如何读取生成的消息。Kafka 还有一个强大的命令,可以从命令行消费消息。请记住,所有这些命令行任务也可以通过编程方式完成。作为生产者,输入中的每一行都被视为生产者的一条消息。

对于本节,需要执行前面的步骤。Kafka 代理必须处于运行状态,并在其中创建一个主题。此外,需要使用消息控制台生产者生成一些消息,以便从控制台开始消费这些消息。

运行以下命令:

> <confluent-path>/bin/kafka-console-consumer --topic amazingTopic --bootstrap-server localhost:9093 --from-beginning

输出应该是以下内容:

Fool me once shame on you
Fool me twice shame on me

参数是主题的名称和代理生产者的名称。此外,--from-beginning参数表示应从日志的开始而不是最后一条消息消费消息(现在测试它,生成更多消息,不要指定此参数)。

此命令还有更多有用的参数,以下是一些重要的参数:

  • --fetch-size: 这是单个请求中要获取的数据量。字节数作为参数跟随。默认值是 1,024 x 1,024。

  • --socket-buffer-size: 这是 TCP RECV 的大小。字节数作为此参数跟随。默认值是 2 x 1024 x 1024。

  • --formater: 这是用于格式化消息以显示的类的名称。默认值是NewlineMessageFormatter

  • --autocommit.interval.ms: 这是保存当前偏移量所用的时间间隔,单位为毫秒。毫秒数作为参数跟随。默认值是 10,000。

  • --max-messages: 这是退出前要消费的最大消息数。如果没有设置,则消费是连续的。消息数作为参数跟随。

  • --skip-message-on-error:如果在处理消息时出现错误,系统应跳过它而不是停止。

此命令最常用的形式如下:

  • 要消费一条消息,请使用以下命令:
 > <confluent-path>/bin/kafka-console-consumer --topic 
      amazingTopic --
      bootstrap-server localhost:9093 --max-messages 1
  • 要从偏移量消费一条消息,请使用以下命令:
 > <confluent-path>/bin/kafka-console-consumer --topic 
      amazingTopic --
      bootstrap-server localhost:9093 --max-messages 1 --formatter 
      'kafka.coordinator.GroupMetadataManager$OffsetsMessageFormatter'
  • 要从特定的消费者组消费消息,请使用以下命令:
 <confluent-path>/bin/kafka-console-consumer –topic amazingTopic -
      - bootstrap-server localhost:9093 --new-consumer --consumer-
      property 
      group.id=my-group

使用 kafkacat

kafkacat 是一个通用的命令行非 JVM 工具,用于测试和调试 Apache Kafka 部署。kafkacat 可以用于生产、消费和列出 Kafka 的主题和分区信息。kafkacat 是 Kafka 的 netcat,它是一个用于检查和创建 Kafka 中数据的工具。

kafkacat 类似于 Kafka 控制台生产者和 Kafka 控制台消费者,但功能更强大。

kafkacat 是一个开源实用程序,它不包括在 Confluent 平台中。它可在 github.com/edenhill/kafkacat 获取。

要在现代 Linux 上安装 kafkacat,请输入以下命令:

apt-get install kafkacat

要在 macOS 上使用 brew 安装 kafkacat,请输入以下命令:

brew install kafkacat

要订阅 amazingTopicredundantTopic 并打印到 stdout,请输入以下命令:

kafkacat -b localhost:9093 –t amazingTopic redundantTopic

摘要

在本章中,我们学习了 Kafka 是什么,如何在 Linux 和 macOS 上安装和运行 Kafka,以及如何安装和运行 Confluent 平台。

此外,我们还回顾了如何运行 Kafka 代理和主题,如何运行命令行消息生产者和消费者,以及如何使用 kafkacat。

在第二章 消息验证 中,我们将分析如何从 Java 中构建生产者和消费者。

第二章:消息验证

第一章,配置 Kafka,专注于如何设置 Kafka 集群并运行命令行生产者和消费者。有了事件生产者,我们现在必须处理这些事件。

在详细说明之前,让我们先介绍我们的案例研究。我们需要对 Monedero 公司的系统进行建模,Monedero 是一家虚构的公司,其核心业务是加密货币交易。Monedero 希望将其 IT 基础设施建立在用 Apache Kafka 构建的企业服务总线ESB)上。Monedero 的 IT 部门希望统一整个组织的服务骨干。Monedero 还有全球性的、基于网页的和基于移动应用的客户,因此实时响应是基本要求。

全球在线客户浏览 Monedero 网站以交换他们的加密货币。客户在 Monedero 可以执行很多用例,但这个例子专注于从 Web 应用程序的具体交换工作流程部分。

本章涵盖以下主题:

  • 以 JSON 格式建模消息

  • 使用 Gradle 设置 Kafka 项目

  • 使用 Java 客户端从 Kafka 读取

  • 使用 Java 客户端向 Kafka 写入

  • 运行处理引擎管道

  • 使用 Java 编写Validator

  • 运行验证

企业服务总线概述

事件处理包括从一个事件流中取出一个或多个事件,并对这些事件应用动作。一般来说,在企业服务总线中,有商品服务;最常见的如下:

  • 数据转换

  • 事件处理

  • 协议转换

  • 数据映射

在大多数情况下,消息处理涉及以下内容:

  • 根据消息架构对消息结构进行验证

  • 给定一个事件流,从流中过滤消息

  • 使用附加数据丰富消息

  • 从两个或多个消息中聚合(组合)以生成新的消息

本章是关于事件验证的。接下来的章节将介绍组合和丰富。

事件建模

事件建模的第一步是将事件用以下形式的英语表达出来:

主语-动词-直接宾语

对于这个例子,我们正在建模事件客户咨询 ETH 价格

  • 这句话的主语是customer,一个名词,在主格。主语是执行动作的实体。

  • 这句话中的动词是consults;它描述了主语执行的动作。

  • 这句话的直接宾语是ETH 价格。宾语是动作被执行的实体。

我们可以用几种消息格式来表示我们的消息(本书的其他章节有所涉及):

  • JavaScript 对象表示法JSON

  • Apache Avro

  • Apache Thrift

  • Protocol Buffers

JSON 易于被人类和机器读取和写入。例如,我们可以选择二进制作为表示方式,但它有一个严格的格式,并且它不是为人类阅读而设计的;作为平衡,二进制表示在处理上非常快速和轻量级。

列表 2.1显示了CUSTOMER_CONSULTS_ETHPRICE事件在 JSON 格式中的表示:

{
  "event": "CUSTOMER_CONSULTS_ETHPRICE",
   "customer": {
         "id": "14862768",
         "name": "Snowden, Edward",
         "ipAddress": "95.31.18.111"
   },
   "currency": {
         "name": "ethereum",
         "price": "RUB"
   },
   "timestamp": "2018-09-28T09:09:09Z"
}

列表 2.1:customer_consults_ETHprice.json

对于这个例子,以太币(ETH)的价格是以俄罗斯卢布(RUB)表示的。这个 JSON 消息有四个部分:

  • event: 这是一个包含事件名称的字符串。

  • customer: 这代表咨询以太币价格的人(在这种情况下,其id14862768)。在这个表示中,有一个唯一的客户 ID、姓名和浏览器的ipAddress,这是客户登录的计算机的 IP 地址。

  • currency: 这包含加密货币的名称以及价格所表示的货币。

  • timestamp: 客户请求时的戳记(UTC)。

从另一个角度来看,消息有两个部分:元数据——这是事件名称和戳记,以及两个业务实体,客户和货币。正如我们所见,这条消息可以被人类阅读和理解。

来自同一用例的其他消息,以 JSON 格式可能如下所示:

{ "event": "CUSTOMER_CONSULTS_ETHPRICE",
   "customer": {
         "id": "13548310",
         "name": "Assange, Julian",
         "ipAddress": "185.86.151.11"
   },
   "currency": {
         "name": "ethereum",
         "price": "EUR"
   },
   "timestamp": "2018-09-28T08:08:14Z"
}

这又是一个例子:

{ "event": "CUSTOMER_CONSULTS_ETHPRICE",
   "customer": {
         "id": "15887564",
         "name": "Mills, Lindsay",
         "ipAddress": "186.46.129.15"
   },
   "currency": {
         "name": "ethereum",
         "price": "USD"
   },
   "timestamp": "2018-09-28T19:51:35Z"
}

如果我们想在 Avro 模式中表示我们的消息会怎样呢?是的,我们的消息的 Avro 模式(注意,这不是消息,而是模式)在列表 2.2中:

{ "name": "customer_consults_ethprice",
  "namespace": "monedero.avro",
  "type": "record",
  "fields": [
    { "name": "event", "type": "string" },
    { "name": "customer",
      "type": {
          "name": "id", "type": "long",
          "name": "name", "type": "string",
          "name": "ipAddress", "type": "string"
      }
    },
    { "name": "currency",
      "type": {
          "name": "name", "type": "string",
          "name": "price", "type": {
          "type": "enum", "namespace": "monedero.avro",
            "name": "priceEnum", "symbols": ["USD", "EUR", "RUB"]}
      }
    },
    { "name": "timestamp", "type": "long",
      "logicalType": "timestamp-millis"
    }
  ]
}

列表 2.2:customer_consults_ethprice.avsc

更多关于 Avro 模式的信息,请查看 Apache Avro 规范:

avro.apache.org/docs/1.8.2/spec.html

设置项目

这次,我们将使用 Gradle 构建我们的项目。第一步是下载并安装 Gradle,可以从www.gradle.org/downloads下载。

Gradle 只需要 Java JDK(版本 7 或更高)。

macOS 用户可以使用brew命令安装 Gradle,如下所示:

$ brew update && brew install gradle

输出类似于以下内容:

==> Downloading https://services.gradle.org/distributions/gradle-4.10.2-all.zip
==> Downloading from https://downloads.gradle.org/distributions/gradle-4.10.2-al
######################################################################## 100.0%
 /usr/local/Cellar/gradle/4.10.2: 203 files, 83.7MB, built in 59 seconds

Linux 用户可以使用apt-get命令安装 Gradle,如下所示:

$ apt-get install gradle

Unix 用户可以使用 sdkman 安装,这是一个用于管理大多数基于 Unix 系统的并行版本的工具,如下所示:

$ sdk install gradle 4.3

要检查 Gradle 是否正确安装,请输入以下内容:

$ gradle -v

输出类似于以下内容:

------------------------------------------------------------
Gradle 4.10.2
------------------------------------------------------------

第一步是创建一个名为monedero的目录,然后从这个目录执行以下操作:

$ gradle init --type java-library

输出类似于以下内容:

...
BUILD SUCCESSFUL
...

Gradle 在目录内生成一个骨架项目。该目录应类似于以下内容:

  • - build.gradle

  • - gradle

  • -- wrapper

  • --- gradle-wrapper.jar

  • --- gradle-vreapper.properties

  • - gradlew

  • - gradle.bat

  • - settings.gradle

  • - src

  • -- main

  • --- java

  • ----- Library.java

  • -- test

  • --- java

  • ----- LibraryTest.java

可以删除两个 Java 文件,Library.javaLibraryTest.java

现在,修改名为build.gradle的 Gradle 构建文件,并用列表 2.3替换它:

apply plugin: 'java'
apply plugin: 'application'
sourceCompatibility = '1.8'
mainClassName = 'monedero.ProcessingEngine'
repositories {
 mavenCentral()
}
version = '0.1.0'
dependencies {
 compile group: 'org.apache.kafka', name: 'kafka_2.12', version: '2.0.0'
 compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: '2.9.7'
}
jar {
 manifest {
 attributes 'Main-Class': mainClassName
 } from {
 configurations.compile.collect {
 it.isDirectory() ? it : zipTree(it)
 }
 }
 exclude "META-INF/*.SF"
 exclude "META-INF/*.DSA"
 exclude "META-INF/*.RSA"
}

列表 2.3:ProcessingEngine Gradle 构建文件

此文件显示了引擎的库依赖项:

  • kafka_2.12是 Apache Kafka 的依赖项

  • jackson-databind是用于 JSON 解析和操作的库

要编译源代码并下载所需的库,请输入以下命令:

$ gradle compileJava

输出类似于以下内容:

...
BUILD SUCCESSFUL
...

项目可以用 Maven 或 SBT 创建,甚至可以从 IDE(IntelliJ、Eclipse、Netbeans)创建。但为了简单起见,这里使用 Gradle 创建。

更多关于构建工具的信息,请访问以下链接:

从 Kafka 读取

既然我们已经有了我们的项目骨架,让我们回顾一下流处理引擎的项目要求。记住,我们的事件客户咨询 ETH 价格发生在 Monedero 之外,并且这些消息可能没有很好地形成,也就是说,它们可能有缺陷。我们管道的第一步是验证输入事件具有正确的数据和正确的结构。我们的项目将被称为ProcessingEngine

ProcessingEngine规范应创建一个执行以下操作的管道应用程序:

  • 从名为input-messages的 Kafka 主题读取每条消息

  • 验证每条消息,将任何无效事件发送到名为invalid-messages的特定 Kafka 主题

  • 将正确的消息写入名为valid-messages的 Kafka 主题

这些步骤在图 2.1中详细说明,这是管道处理引擎的第一个草图:

图片

图 2.1:处理引擎从输入消息主题读取事件,验证消息,并将有缺陷的消息路由到无效消息主题,正确的消息路由到有效消息主题

处理引擎流构建有两个阶段:

  • 创建一个简单的 Kafka 工作器,从 Kafka 的input-messages主题读取并写入另一个主题的事件

  • 修改 Kafka 工作器以进行验证

因此,让我们进行第一步。构建一个 Kafka 工作器,从input-messages主题读取单个原始消息。我们在 Kafka 术语中说需要一个消费者。如果你还记得,在第一章中我们构建了一个命令行生产者将事件写入主题,以及一个命令行消费者从该主题读取事件。现在,我们将用 Java 编写相同的消费者。

对于我们的项目,消费者是一个 Java 接口,它包含所有实现消费者类的必要行为。

src/main/java/monedero/目录下创建一个名为Consumer.java的文件,其内容为列表 2.4

package monedero;
import java.util.Properties;
public interface Consumer {
  static Properties createConfig(String servers, String groupId) {
    Properties config = new Properties();
    config.put("bootstrap.servers", servers);
    config.put("group.id", groupId);
    config.put("enable.auto.commit", "true");
    config.put("auto.commit.interval.ms", "1000");
    config.put("auto.offset.reset", "earliest");
    config.put("session.timeout.ms", "30000");
    config.put("key.deserializer",
        "org.apache.kafka.common.serialization.StringDeserializer");
    config.put("value.deserializer",
        "org.apache.kafka.common.serialization.StringDeserializer");
    return config;
  }
}

列表 2.4:Consumer.java

消费者接口封装了 Kafka 消费者的通用行为。消费者接口有一个 createConfig 方法,用于设置所有 Kafka 消费者所需的属性。请注意,反序列化器是 StringDeserializer 类型,因为 Kafka 消费者读取的 Kafka 键值记录中的值是字符串类型。

现在,在 src/main/java/monedero/ 目录下创建一个名为 Reader.java 的文件,其内容为 清单 2.5

package monedero;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import java.time.Duration;
import java.util.Collections;
class Reader implements Consumer {
  private final KafkaConsumer<String, String> consumer;//1
  private final String topic;
  Reader(String servers, String groupId, String topic) {
    this.consumer =
        new KafkaConsumer<>(Consumer.createConfig(servers, groupId));
    this.topic = topic;
  }
  void run(Producer producer) {
    this.consumer.subscribe(Collections.singletonList(this.topic));//2
 while (true) {//3
      ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));  //4
      for (ConsumerRecord<String, String> record : records) {
producer.process(record.value());//5
      }
    }
  }
}

清单 2.5:Reader.java

Reader 类实现了消费者接口。因此,Reader 是一个 Kafka 消费者:

  • 在行 //1 中,<String, String> 表示 KafkaConsumer 读取的 Kafka 记录中键和值都是字符串类型

  • 在行 //2 中,消费者订阅了其构造函数中指定的 Kafka 主题

  • 在行 //3 中,有一个用于演示目的的 while(true) 无限循环;在实际应用中,我们可能需要处理更健壮的代码,可能实现 Runnable

  • 在行 //4 中,这个消费者将每隔 100 毫秒从指定的主题中收集数据

  • 在行 //5 中,消费者将待处理的消息发送给生产者

此消费者从指定的 Kafka 主题读取所有消息,并将它们发送到指定生产者的 process 方法。所有配置属性都在消费者接口中指定,但具体来说,groupId 属性很重要,因为它将消费者与特定的消费者组关联起来。

当我们需要在所有组成员之间共享主题的事件时,消费者组非常有用。消费者组还用于将不同的实例分组或隔离。

要了解更多关于 Kafka 消费者 API 的信息,请点击此链接:kafka.apache.org/20/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html/

写入 Kafka

我们的 Reader 调用了 process() 方法;这个方法属于 Producer 类。与消费者接口一样,生产者接口封装了 Kafka 生产者的所有通用行为。本章中的两个生产者实现了这个生产者接口。

src/main/java/monedero 目录下,一个名为 Producer.java 的文件中,复制 清单 2.6 的内容:

package monedero;
import java.util.Properties;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
public interface Producer {
  void process(String message);                                 //1
  static void write(KafkaProducer<String, String> producer,
                    String topic, String message) {             //2
    ProducerRecord<String, String> pr = new ProducerRecord<>(topic, message);
    producer.send(pr);
  }
  static Properties createConfig(String servers) {              //3
    Properties config = new Properties();
    config.put("bootstrap.servers", servers);
    config.put("acks", "all");
    config.put("retries", 0);
    config.put("batch.size", 1000);
    config.put("linger.ms", 1);
    config.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
config.put("value.serializer",
        "org.apache.kafka.common.serialization.StringSerializer"); 
         return config;
}
}

清单 2.6:Producer.java

生产者接口有以下观察结果:

  • Reader 类中调用的名为 process 的抽象方法

  • 一个名为 write 的静态方法,它将消息发送到指定主题的生产者

  • 一个名为 createConfig 的静态方法,其中设置了通用生产者所需的全部属性

与消费者接口一样,需要一个生产者接口的实现。在这个第一个版本中,我们只是将传入的消息传递到另一个主题,而不修改消息。实现代码在 清单 2.7 中,应保存为 src/main/java/m 目录下的 Writer.java 文件。

以下为列表 2.7Writer.java的内容:

package monedero;
import org.apache.kafka.clients.producer.KafkaProducer;
public class Writer implements Producer {
  private final KafkaProducer<String, String> producer;
  private final String topic;
  Writer(String servers, String topic) {
    this.producer = new KafkaProducer<>(
        Producer.createConfig(servers));//1
    this.topic = topic;
  }
  @Override
  public void process(String message) {
    Producer.write(this.producer, this.topic, message);//2
  }
}

列表 2.7:Writer.java

Producer类的这个实现中,我们可以看到以下内容:

  • createConfig方法被调用,以从生产者接口设置必要的属性

  • process方法将每条传入的消息写入输出主题。随着消息从主题到达,它被发送到目标主题

这个生产者实现非常简单;它不修改、验证或丰富消息。它只是将它们写入输出主题。

要了解更多关于 Kafka 生产者 API 的信息,请点击以下链接:

kafka.apache.org/0110/javadoc/index.html?org/apache/kafka/clients/consumer/KafkaProducer.html

运行处理引擎

ProcessingEngine类协调ReaderWriter类。它包含协调它们的主方法。在src/main/java/monedero/目录下创建一个名为ProcessingEngine.java的新文件,并将列表 2.8中的代码复制进去。

以下为列表 2.8ProcessingEngine.java的内容:

package monedero;
public class ProcessingEngine {
  public static void main(String[] args) {
    String servers = args[0];
    String groupId = args[1];
    String sourceTopic = args[2];
    String targetTopic = args[3];
    Reader reader = new Reader(servers, groupId, sourceTopic);
    Writer writer = new Writer(servers, targetTopic);
    reader.run(writer);
  }
}

列表 2.8:ProcessingEngine.java

ProcessingEngine从命令行接收四个参数:

  • args[0] servers,Kafka 代理的主机和端口

  • args[1] groupId,消费者的消费者组

  • args[2] sourceTopicinputTopicReader从中读取

  • args[3] targetTopicoutputTopicWriter写入到

要构建项目,从monedero目录运行以下命令:

$ gradle jar

如果一切正常,输出将类似于以下内容:

...
BUILD SUCCESSFUL
...

要运行项目,我们需要打开三个不同的命令行窗口。图 2.2显示了命令行窗口应该看起来是什么样子:

图 2.2:测试处理引擎的三个终端窗口,包括消息生产者、消息消费者以及应用程序本身

  1. 在第一个命令行终端中,切换到Confluent目录并启动它,如下所示:
$ bin/confluent start
  1. 一旦控制中心(包括 Zookeeper 和 Kafka)在同一命令行终端中运行,按照以下方式生成两个必要的主题:
$ bin/kafka-topics --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic input-topic

$ bin/kafka-topics --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic output-topic

回想一下,要显示我们集群中运行的主题,请使用以下命令:

$ bin/kafka-topics --list --zookeeper localhost:2181

如果有误输入,要删除某些主题(以防万一),请输入以下命令:

$ bin/kafka-topics --delete --zookeeper localhost:2181 --topic 
unWantedTopic
  1. 在同一命令行终端中,启动运行input-topic主题的控制台生产者,如下所示:
$ bin/kafka-console-producer --broker-list localhost:9092 --topic 
input-topic

这个窗口是输入消息的地方。

  1. 在第二个命令行终端中,通过输入以下内容启动监听output-topic的控制台消费者:
$ bin/kafka-console-consumer --bootstrap-server localhost:9092 --
from-beginning --topic output-topic
  1. 在第三个命令行终端中,启动处理引擎。转到执行gradle jar命令的项目根目录并运行,如下所示:
$ java -jar ./build/libs/monedero-0.1.0.jar localhost:9092 foo 
input-topic output-topic

现在,展示的内容包括从input-topic读取所有事件并将它们写入output-topic

前往第一个命令行终端(消息生产者),发送以下三条消息(记得在消息之间按回车,并且每条消息只执行一行):

{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "14862768", "name": "Snowden, Edward", "ipAddress": "95.31.18.111"}, "currency": {"name": "ethereum", "price": "RUB"}, "timestamp": "2018-09-28T09:09:09Z"}

{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "13548310", "name": "Assange, Julian", "ipAddress": "185.86.151.11"}, "currency": {"name": "ethereum", "price": "EUR"}, "timestamp": "2018-09-28T08:08:14Z"}

{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "15887564", "name": "Mills, Lindsay", "ipAddress": "186.46.129.15"}, "currency": {"name": "ethereum", "price": "USD"}, "timestamp": "2018-09-28T19:51:35Z"}

如果一切正常,控制台生产者中输入的消息应该出现在控制台消费者窗口中,因为处理引擎正在从 input-topic 复制到 output-topic

下一步是将更复杂的版本涉及消息验证(当前章节)、消息丰富(第三章,消息丰富)和消息转换(第四章,序列化)。

使用第一章中提出的相同建议,配置 Kafka,将复制因子和分区参数设置为 1;尝试设置不同的值,并观察停止一个服务器时会发生什么。

用 Java 编写验证器

Writer 类实现了生产者接口。想法是修改这个 Writer 并以最小的努力构建一个验证类。Validator 处理过程如下:

  • 输入消息主题读取 Kafka 消息

  • 验证消息,将不合格的消息发送到无效消息主题

  • 将格式良好的消息写入有效消息主题

目前,对于这个例子,有效消息的定义是一个满足以下条件的消息:

  • 它是 JSON 格式

  • 它包含四个必需的字段:事件、客户、货币和时间

如果这些条件不满足,将生成一个新的 JSON 格式错误消息,并将其发送到无效消息 Kafka 主题。这个错误消息的架构非常简单:

{"error": "Failure description" }

第一步是在 src/main/java/monedero/ 目录中创建一个新的 Validator.java 文件,并复制其中 列表 2.9 的内容。

以下为 列表 2.9Validator.java 的内容:

package monedero;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.kafka.clients.producer.KafkaProducer;
import java.io.IOException;

public class Validator implements Producer {
  private final KafkaProducer<String, String> producer;
  private final String validMessages;
  private final String invalidMessages;
  private static final ObjectMapper MAPPER = new ObjectMapper();

  public Validator(String servers, String validMessages, String invalidMessages) { //1
    this.producer = new KafkaProducer<>(Producer.createConfig(servers));
    this.validMessages = validMessages;
    this.invalidMessages = invalidMessages;
  }

  @Override
  public void process(String message) {
    try {
      JsonNode root = MAPPER.readTree(message);
      String error = "";
      error = error.concat(validate(root, "event")); //2
      error = error.concat(validate(root, "customer"));
      error = error.concat(validate(root, "currency"));
      error = error.concat(validate(root, "timestamp"));
      if (error.length() > 0) {
        Producer.write(this.producer, this.invalidMessages, //3
        "{\"error\": \" " + error + "\"}");
      } else {
        Producer.write(this.producer, this.validMessages, //4
        MAPPER.writeValueAsString(root));
      }
    } catch (IOException e) {
      Producer.write(this.producer, this.invalidMessages, "{\"error\": \""
      + e.getClass().getSimpleName() + ": " + e.getMessage() + "\"}");//5 
    }
  }
  private String validate(JsonNode root, String path) {
    if (!root.has(path)) {
      return path.concat(" is missing. ");
    }
    JsonNode node = root.path(path);
    if (node.isMissingNode()) {
      return path.concat(" is missing. ");
    }
    return "";
  }
}

列表 2.9:Validator.java

Writer 类一样,Validator 类也实现了 Producer 类,但如下:

  • 在第 //1 行,其构造函数接受两个主题:有效消息主题和无效消息主题

  • 在第 //2 行,process 方法验证消息是否为 JSON 格式,以及字段:事件、客户、货币和时间的存在

  • 在第 //3 行,如果消息没有任何必需的字段,则将错误消息发送到无效消息主题

  • 在第 //4 行,如果消息有效,则将消息发送到有效消息主题

  • 在第 //5 行,如果消息不是 JSON 格式,则将错误消息发送到无效消息主题

运行验证

目前,ProcessingEngine 类协调 ReaderWriter 类。它包含协调它们的主要方法。我们必须编辑位于 src/main/java/monedero/ 目录中的 ProcessingEngine 类,并将 Writer 替换为 Validator,如列表 2.10所示。

以下为列表 2.10ProcessingEngine.java的内容:

package monedero;
public class ProcessingEngine {
  public static void main(String[] args) {
    String servers = args[0];
    String groupId = args[1];
    String inputTopic = args[2];
    String validTopic = args[3];
    String invalidTopic = args[4];
    Reader reader = new Reader(servers, groupId, inputTopic);
    Validator validator = new Validator(servers, validTopic, invalidTopic);
    reader.run(validator);
  }
}

列表 2.10:ProcessingEngine.java

ProcessingEngine从命令行接收五个参数:

  • args[0] servers,表示 Kafka 代理的主机和端口

  • args[1] groupId,表示消费者是这个 Kafka 消费者组的一部分

  • args[2] inputTopicReader读取的主题

  • args[3] validTopic,有效消息发送的主题

  • args[4] invalidTopic,无效消息发送的主题

要从monedero目录重新构建项目,运行以下命令:

$ gradle jar

如果一切正常,输出应该类似于以下内容:

...
BUILD SUCCESSFUL
...

要运行项目,我们需要四个不同的命令行窗口。图 2.3显示了命令行窗口的布局:

图片

图 2.3:测试处理引擎的四个终端窗口,包括:消息生产者、有效消息消费者、无效消息消费者和处理引擎本身

  1. 在第一个命令行终端中,进入 Kafka 安装目录并生成两个必要的主题:
$ bin/kafka-topics --create --zookeeper localhost:2181 --
replication-factor 1 --partitions 1 --topic valid-messages

$ bin/kafka-topics --create --zookeeper localhost:2181 --
replication-factor 1 --partitions 1 --topic invalid-messages

然后,启动一个向input-topic主题的控制台生产者,如下所示:

$ bin/kafka-console-producer --broker-list localhost:9092 --topic 
input-topic

这个窗口是输入消息被产生(输入)的地方。

  1. 在第二个命令行窗口中,启动一个监听有效消息主题的命令行消费者,如下所示:
$ bin/kafka-console-consumer --bootstrap-server localhost:9092 --
from-beginning --topic valid-messages
  1. 在第三个命令行窗口中,启动一个监听无效消息主题的命令行消费者,如下所示:
$ bin/kafka-console-consumer --bootstrap-server localhost:9092 --
from-beginning --topic invalid-messages
  1. 在第四个命令行终端中,启动处理引擎。从项目根目录(执行gradle jar命令的地方),运行以下命令:
$ java -jar ./build/libs/monedero-0.1.0.jar localhost:9092 foo 
input-topic valid-messages invalid-messages

从第一个命令行终端(控制台生产者),发送以下三条消息(记住在消息之间按回车,并且每条消息只执行一行):

{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "14862768", "name": "Snowden, Edward", "ipAddress": "95.31.18.111"}, "currency": {"name": "ethereum", "price": "RUB"}, "timestamp": "2018-09-28T09:09:09Z"}

{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "13548310", "name": "Assange, Julian", "ipAddress": "185.86.151.11"}, "currency": {"name": "ethereum", "price": "EUR"}, "timestamp": "2018-09-28T08:08:14Z"}

{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "15887564", "name": "Mills, Lindsay", "ipAddress": "186.46.129.15"}, "currency": {"name": "ethereum", "price": "USD"}, "timestamp": "2018-09-28T19:51:35Z"}

由于这些是有效消息,生产者控制台输入的消息应该出现在有效消息消费者控制台窗口中。

现在尝试发送有缺陷的消息;首先,尝试不是 JSON 格式的消息:

I am not JSON, I am Freedy. [enter]
I am a Kafkeeter! [enter]

这条消息应该被接收在无效消息主题中(并在窗口中显示),如下所示:

{"error": "JsonParseException: Unrecognized token ' I am not JSON, I am Freedy.': was expecting 'null','true', 'false' or NaN
at [Source: I am not JSON, I am Freedy.; line: 1, column: 4]"}

然后,让我们尝试更复杂的事情,第一条消息但没有时间戳,就像在示例中那样:

{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "14862768", "name": "Snowden, Edward", "ipAddress": "95.31.18.111"}, "currency": {"name": "ethereum", "price": "RUB"}}

这条消息应该被接收在无效消息主题中,如下所示:

{"error": "timestamp is missing."}

消息验证已完成,如您所见,还有更多验证要做,例如,验证 JSON 模式,但这将在第五章,“模式注册”中介绍。

本章中详细介绍的图 2.1的架构将在第三章,“消息丰富”中使用。

摘要

在本章中,我们学习了如何对 JSON 格式的消息进行建模,以及如何使用 Gradle 设置 Kafka 项目。

此外,我们还学习了如何使用 Java 客户端向 Kafka 写入和读取数据,如何运行处理引擎,如何用 Java 编写验证器,以及如何运行消息验证。

在第三章“消息增强”中,本章的架构将被重新设计以包含消息增强。

第三章:消息丰富

要完全理解本章内容,必须阅读上一章,该章重点介绍了如何验证事件。本章重点介绍如何丰富事件。

在本章中,我们将继续使用 Monedero 系统,这是我们虚构的公司,致力于加密货币的交换。如果我们回顾上一章,Monedero 的消息已被验证;在本章中,我们将继续使用相同的流程,但我们将增加一个额外的丰富步骤。

在这个背景下,我们将丰富理解为添加原始消息中不存在的数据。在本章中,我们将看到如何使用 MaxMind 数据库丰富消息的地理位置,以及如何使用 Open Exchange 数据提取当前汇率。如果我们还记得为 Monedero 建模的事件,每个事件都包括了客户电脑的 IP 地址。

在本章中,我们将使用 MaxMind 免费数据库,它为我们提供了一个包含 IP 地址与其地理位置映射的 API。

Monedero 系统在我们的客户请求系统时,会在 MaxMind 数据库中搜索客户的 IP 地址,以确定客户的位置。使用外部数据源将它们添加到我们的事件中,我们称之为消息丰富。

在加密货币的世界中,有一种称为 Bit License 的东西,其中一些地理区域根据法律限制进行与加密货币相关的活动。我们目前为 Monedero 提供了一种事件验证服务。

然而,法律部门要求我们有一个验证过滤器,以了解客户的地理位置,从而能够遵守 Bit License。Bit License 自 2014 年 7 月在新泽西地区运营,适用于居民。根据法律条款,被认为是居民的人包括所有居住、位于、有业务场所或在纽约州开展业务的人。

本章涵盖了以下主题:

  • 提取工作原理

  • 丰富工作原理

  • 根据 IP 地址提取位置

  • 根据货币提取货币价格

  • 根据位置提取天气数据

  • 使用地理位置丰富消息

  • 使用货币价格丰富消息

  • 运行处理引擎

提取地理位置

打开在第二章“消息验证”中创建的 Monedero 项目中的build.gradle文件,并添加列表 3.1中突出显示的行。

以下为列表 3.1的内容,即 Monedero 的build.gradle文件:

apply plugin: 'java'
apply plugin: 'application'
sourceCompatibility = '1.8'
mainClassName = 'monedero.ProcessingEngine'
repositories {
  mavenCentral()
}
version = '0.2.0'
dependencies {
    compile group: 'org.apache.kafka', name: 'kafka_2.12', version:                                                                                                                                              
                                                            '2.0.0'
    compile group: 'com.maxmind.geoip', name: 'geoip-api', version:                                         
                                                            '1.3.1'
    compile group: 'com.fasterxml.jackson.core', name: 'jackson-core', version: '2.9.7'
}
jar {
  manifest {
    attributes 'Main-Class': mainClassName
  } from {
    configurations.compile.collect {
      it.isDirectory() ? it : zipTree(it)
    }
  }
  exclude "META-INF/*.SF"
  exclude "META-INF/*.DSA"
  exclude "META-INF/*.RSA"
}

列表 3.1:build.gradle

注意,第一个更改是从版本 0.1.0 切换到版本 0.2.0。

第二个更改是将 MaxMind 的 GeoIP 版本 1.3.1 添加到我们的项目中。

从项目根目录运行以下命令以重新构建应用程序:

$ gradle jar

输出结果类似于以下内容:

...BUILD SUCCESSFUL in 8s
2 actionable tasks: 2 executed

要下载 MaxMind GeoIP 免费数据库的副本,请执行此命令:

$ wget "http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz"

运行以下命令来解压缩文件:

$ gunzip GeoLiteCity.dat.gz

GeoLiteCity.dat 文件移动到程序可访问的路由中。

现在,在 src/main/java/monedero/extractors 目录中添加一个名为 GeoIPService.java 的文件,包含 Listing 3.2 的内容:

package monedero.extractors;
import com.maxmind.geoip.Location;
import com.maxmind.geoip.LookupService;
import java.io.IOException;
import java.util.logging.Level;
import java.util.logging.Logger;
public final class GeoIPService {
  private static final String MAXMINDDB = "/path_to_your_GeoLiteCity.dat_file";
  public Location getLocation(String ipAddress) {
    try {
      final LookupService maxmind = 
        new LookupService(MAXMINDDB, LookupService.GEOIP_MEMORY_CACHE);
      return maxmind.getLocation(ipAddress);
    } catch (IOException ex) {
      Logger.getLogger(GeoIPService.class.getName()).log(Level.SEVERE, null, ex);
    }
    return null;
  }
}

列表 3.2:GeoIPService.java

GeoIPService 有一个公共方法 getLocation,它接收一个表示 IP 地址的字符串,并在 GeoIP 位置数据库中查找该 IP 地址。此方法返回一个包含该特定 IP 地址地理位置的 location 类对象。

有时会有一些要求严格的客户要求获取数据库的最新版本。在这种情况下,持续下载数据库不是一个选择。为此类情况,MaxMind 通过 API 提供其服务。要了解更多信息,请访问以下 URL:dev.maxmind.com/

要了解更多关于位许可证法规的信息,请访问以下链接:

www.dfs.ny.gov/legal/regulations/bitlicense_reg_framework.html

丰富消息

现在,我们将回顾 Monedero 处理引擎的步骤。客户在客户端浏览器中咨询 ETH 价格,并通过一些 HTTP 事件收集器发送到 Kafka。

我们流程中的第一步是事件正确性验证;记得从上一章中,有缺陷的消息是从不良数据中派生出来的,这就是为什么它们被过滤掉。现在第二步是丰富我们的消息,添加地理信息。

这里是 Monedero 处理引擎的架构步骤:

  1. 从名为 input-messages 的 Kafka 主题中读取单个事件

  2. 验证消息,将任何有缺陷的事件发送到名为 invalid-messages 的专用 Kafka 主题

  3. 丰富消息的地理信息

  4. 在名为 valid-messages 的 Kafka 主题中写入增强的消息

所有这些步骤都详细地列在 Figure 3.1 中:

图 3.1:处理引擎从输入消息主题读取事件,验证消息,将错误发送到无效消息主题,丰富消息的地理信息,然后将它们写入有效消息主题。

现在,让我们在 src/main/java/monedero/ 目录中创建一个名为 Enricher.java 的文件,包含 Listing 3.**3 的内容:

package monedero;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.maxmind.geoip.Location;
import monedero.extractors.GeoIPService;
import org.apache.kafka.clients.producer.KafkaProducer;
import java.io.IOException;

public final class Enricher implements Producer {
  private final KafkaProducer<String, String> producer;
  private final String validMessages;
  private final String invalidMessages;
  private static final ObjectMapper MAPPER = new ObjectMapper();
  public Enricher(String servers, String validMessages, String 
    invalidMessages) {
    this.producer = new KafkaProducer<> 
    (Producer.createConfig(servers));
    this.validMessages = validMessages;
    this.invalidMessages = invalidMessages;
  }
  @Override
  public void process(String message) {
    try {
      // this method below is filled below  
    } catch (IOException e) {
      Producer.write(this.producer, this.invalidMessages, "{\"error\": \""
          + e.getClass().getSimpleName() + ": " + e.getMessage() + "\"}");
    }
  }
}

如预期,Enricher 类实现了生产者接口;因此,Enricher 是一个生产者。

让我们来填写 process() 方法的代码。

如果客户消息没有 IP 地址,该消息将自动发送到 invalid-messages 主题,如下所示:

      final JsonNode root = MAPPER.readTree(message);
      final JsonNode ipAddressNode =   
        root.path("customer").path("ipAddress");
      if (ipAddressNode.isMissingNode()) {
        Producer.write(this.producer, this.invalidMessages,
            "{\"error\": \"customer.ipAddress is missing\"}");
      } else {
        final String ipAddress = ipAddressNode.textValue();

Enricher 类调用 GeoIPServicegetLocation 方法,如下所示:

final Location location = new GeoIPService().getLocation(ipAddress);

位置的国家和城市被添加到客户消息中,如下例所示:

        ((ObjectNode) root).with("customer").put("country",  
             location.countryName); 
        ((ObjectNode) root).with("customer").put("city", 
             location.city);

丰富的消息被写入valid-messages队列,如下所示:

        Producer.write(this.producer, this.validMessages, 
           MAPPER.writeValueAsString(root));
    }

注意,位置对象带来了更多有趣的数据;对于这个例子,只提取城市和国家。例如,MaxMind 数据库可以给我们比这个例子中使用的更精确的数据。实际上,在线 API 可以准确地显示 IP 的确切位置。

还要注意,这里我们有一个非常简单的验证。在下一章中,我们将看到如何验证模式正确性。目前,考虑其他缺失的验证,以使系统满足业务需求。

提取货币价格

目前,Monedero 有一个验证格式良好的消息的服务。该服务还通过客户的地理位置丰富消息。

回想一下,Monedero 的核心业务是加密货币交易。因此,现在业务要求我们提供一个在特定时间返回请求货币价格的在线服务。

为了实现这一点,我们将使用开放汇率交易所的汇率:

openexchangerates.org/

要获得一个免费的 API 密钥,您必须注册免费计划;密钥是访问免费 API 所需的。

现在,让我们在src/main/java/monedero/extractors目录下创建一个名为OpenExchangeService.java的文件,其内容为清单 3.4

package monedero.extractors;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URL;
import java.util.logging.Level;
import java.util.logging.Logger;
public final class OpenExchangeService {
  private static final String API_KEY = "YOUR_API_KEY_VALUE_HERE";  //1
  private static final ObjectMapper MAPPER = new ObjectMapper();
  public double getPrice(String currency) {
    try {
      final URL url = new URL("https://openexchangerates.org/api/latest.json?app_id=" + API_KEY);  //2
      final JsonNode root = MAPPER.readTree(url);
      final JsonNode node = root.path("rates").path(currency);   //3
      return Double.parseDouble(node.toString());                //4
    } catch (IOException ex) {
   Logger.getLogger(OpenExchangeService.class.getName()).log(Level.SEVERE, null, ex);
    }
    return 0;
  }
}

可以如下分析OpenExchangeService类的一些行:

  • 在行//1中,当你在开放汇率页面上注册时,分配API_KEY的值;免费计划每月最多提供 1,000 次请求。

  • 在行//2中,我们的类调用开放交换 API URL,使用您的API_KEY。要检查当前的价格,您可以访问 URL(使用您的密钥进行请求):openexchangerates.org/api/latest.json?app_id=YOUR_API_KEY

  • 在行//3中,作为参数传递的货币字符串在返回网页的 JSON 树中进行搜索。

  • 在行//4中,返回作为参数传递的货币的(美元)货币价格,作为一个双精度值。

解析 JSON 有几种方法,整本书都致力于这个主题。对于这个例子,我们使用了 Jackson 来解析 JSON。要获取更多信息,请访问以下 URL:

github.com/FasterXML

与 MaxMind 地理定位服务一样,开放汇率交易所也通过 API 公开他们的服务。要了解更多信息,请访问以下 URL:

docs.openexchangerates.org/

这个例子使用了开放汇率交易所的免费计划;如果需要非限制 API,请检查他们的其他计划,URL 如下:

openexchangerates.org/signup

增加货币价格

客户咨询 ETH 价格事件,从客户端的网页浏览器开始,并通过一些 HTTP 事件收集器分发到 Kafka。第二步是从 MaxMind 数据库中获取地理信息来丰富消息。第三步是从公开汇率服务中获取货币价格来丰富消息。

总结来说,以下是 Monedero 处理引擎的架构步骤:

  1. 从名为 input-messages 的 Kafka 主题中读取单个事件

  2. 验证消息,将任何有缺陷的事件发送到名为 invalid-messages 的特定 Kafka 主题。

  3. 使用 MaxMind 数据库中的地理信息丰富消息

  4. 使用公开汇率服务丰富消息中的货币价格

  5. 将丰富的消息写入名为 valid-messages 的 Kafka 主题。

流处理引擎的最终版本在 图 3.2 中详细说明:

图片

图 3.2:处理引擎从输入消息主题读取消息,验证消息,将有缺陷的消息路由到 invalid-messages 队列,使用地理信息和价格丰富消息,并最终将它们写入 valid-messages 队列。

要将公开汇率服务添加到我们的引擎中,修改位于 src/main/java/monedero/ 目录下的 Enricher.java 文件,并按照 清单 3.5 中高亮显示的更改进行:

package monedero;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.maxmind.geoip.Location;
import monedero.extractors.GeoIPService;
import monedero.extractors.OpenExchangeService; //1
import org.apache.kafka.clients.producer.KafkaProducer;
import java.io.IOException;
public final class Enricher implements Producer {
  private final KafkaProducer<String, String> producer;
  private final String validMessages;
  private final String invalidMessages;
  private static final ObjectMapper MAPPER = new ObjectMapper();
  public Enricher(String servers, String validMessages, String invalidMessages) {
    this.producer = new KafkaProducer<>(Producer.createConfig(servers));
    this.validMessages = validMessages;
    this.invalidMessages = invalidMessages;
  }
  @Override
  public void process(String message) {
    try {
      final JsonNode root = MAPPER.readTree(message);
      final JsonNode ipAddressNode = root.path("customer").path("ipAddress");
      if (ipAddressNode.isMissingNode()) { //2
        Producer.write(this.producer, this.invalidMessages,
           "{\"error\": \"customer.ipAddress is missing\"}");
      } else {
        final String ipAddress = ipAddressNode.textValue();
        final Location location = new GeoIPService().getLocation(ipAddress);
        ((ObjectNode) root).with("customer").put("country", location.countryName);
        ((ObjectNode) root).with("customer").put("city", location.city);
        final OpenExchangeService oes = new OpenExchangeService(); //3
        ((ObjectNode) root).with("currency").put("rate", oes.getPrice("BTC"));//4
        Producer.write(this.producer, this.validMessages, MAPPER.writeValueAsString(root)); //5
      }
    } catch (IOException e) {
      Producer.write(this.producer, this.invalidMessages, "{\"error\": \""
          + e.getClass().getSimpleName() + ": " + e.getMessage() + "\"}");
    }
  }
}

如我们所知,Enricher 类是一个 Kafka 生产者,因此现在让我们分析新增的内容:

  • 在行 //1 中,我们导入先前构建的 OpenExchangeService

  • 在行 //2 中,为了避免后续的空指针异常,如果消息在客户处没有有效的 IP 地址,则消息会自动发送到 invalid-messages 队列。

  • 在行 //3 中,生成 OpenExchangeService 类的实例,该类是一个提取器。

  • 在行 //4 中,调用 OpenExchangeService 类的 getPrice() 方法,并将此值添加到消息中:将货币价格添加到叶价格中的货币节点。

  • 在行 //5 中,丰富的消息被写入 valid-messages 队列。

这是 Monedero 的 enricher 引擎的最终版本;正如我们所见,管道架构使用提取器作为 enricher 的输入。接下来,我们将看到如何运行我们的整个项目。

注意,JSON 响应包含大量更多信息,但在这个例子中,只使用了一个货币价格。有几个公开数据倡议是免费的,并提供大量免费存储库,包括在线和历史数据。

运行引擎

现在,Enricher 类的最终版本已经编码完成,我们必须编译并执行它。

如我们所知,ProcessingEngine 类包含协调读取器和写入器类的主要方法。现在,让我们修改位于 src/main/java/monedero/ 目录下的 ProcessingEngine.java 文件,并将 Validator 替换为如 清单 3.6 中高亮显示的 Enricher

package monedero;
public class ProcessingEngine {
  public static void main(String[] args){
    String servers = args[0];
    String groupId = args[1];
    String sourceTopic = args[2];
    String validTopic = args[3];
    String invalidTopic = args[4];
    Reader reader = new Reader(servers, groupId, sourceTopic);
    Enricher enricher = new Enricher(servers, validTopic, invalidTopic);
    reader.run(enricher);
  }
}

列表 3.6:ProcessingEngine.java

处理引擎从命令行接收以下五个参数:

  • args[0] servers 表示 Kafka 代理的主机和端口

  • args[1] groupId 表示消费者是这个 Kafka 消费者组的一部分

  • args[2] input topic 表示读取者读取的主题

  • args[3] validTopic 表示发送有效消息的主题

  • args[4] invalidTopic 表示发送无效消息的主题

要从 monedero 目录重新构建项目,请运行以下命令:

$ gradle jar

如果一切正常,输出应该类似于以下内容:

...
BUILD SUCCESSFUL in 8s
2 actionable tasks: 2 executed

要运行项目,我们需要四个不同的命令行窗口。图 3.3 展示了命令行窗口的布局:

图片

图 3.3:测试处理引擎的四个终端窗口,包括:消息生产者、有效消息消费者、无效消息消费者和处理引擎本身

  1. 在第一个命令行终端,进入 Kafka 安装目录并生成两个必要的主题,如下所示:
$ bin/kafka-topics --create --zookeeper localhost:2181 --
replication-factor 1 --
partitions 1 --topic valid-messages
$ bin/kafka-topics --create --zookeeper localhost:2181 --
replication-factor 1 --
partitions 1 --topic invalid-messages

然后,启动一个指向 input-topic 主题的控制台生产者,如下所示:

$ bin/kafka-console-producer --broker-list localhost:9092 --topic 
input-topic

这个窗口是输入消息被产生(输入)的地方。

  1. 在第二个命令行窗口中,启动一个监听 valid-messages 主题的命令行消费者,如下所示:
$ bin/kafka-console-consumer --bootstrap-server localhost:9092 --
from-beginning -
-topic valid-messages
  1. 在第三个命令行窗口中,启动一个监听 invalid-messages 主题的命令行消费者,如下所示:
$ bin/kafka-console-consumer --bootstrap-server localhost:9092 --
from-beginning -
-topic invalid-messages
  1. 在第四个命令行终端中,启动处理引擎。从项目根目录(执行 gradle jar 命令的地方)运行此命令:
$ java -jar ./build/libs/monedero-0.2.0.jar localhost:9092 foo 
input-topic valid-
messages invalid-messages

在第一个命令行终端(控制台生产者),发送以下三条消息(记得在消息之间按回车,并且每条消息只执行一行):

{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "14862768", "name": "Snowden, Edward", "ipAddress": "95.31.18.111"}, "currency": {"name": "ethereum", "price": "USD"}, "timestamp": "2018-09-28T09:09:09Z"}
{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "13548310", "name": "Assange, Julian", "ipAddress": "185.86.151.11"}, "currency": {"name": "ethereum", "price": "USD"}, "timestamp": "2018-09-28T08:08:14Z"}
{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "15887564", "name": "Mills, Lindsay", "ipAddress": "186.46.129.15"}, "currency": {"name": "ethereum", "price": "USD"}, "timestamp": "2018-09-28T19:51:35Z"}

由于这些是有效消息,生产者控制台输入的消息应该出现在有效消息消费者控制台窗口中,如下例所示:

{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "14862768", "name": "Snowden, Edward", "ipAddress": "95.31.18.111", "country":"Russian Federation","city":"Moscow"}, "currency": {"name": "ethereum", "price": "USD", "rate":0.0049}, "timestamp": "2018-09-28T09:09:09Z"}
{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "13548310", "name": "Assange, Julian", "ipAddress": "185.86.151.11", "country":"United Kingdom","city":"London"}, "currency": {"name": "ethereum", "price": "USD", "rate":0.049}, "timestamp": "2018-09-28T08:08:14Z"}
{"event": "CUSTOMER_CONSULTS_ETHPRICE", "customer": {"id": "15887564", "name": "Mills, Lindsay", "ipAddress": "186.46.129.15", "country":"Ecuador","city":"Quito"}, "currency": {"name": "ethereum", "price": "USD", "rate":0.049}, "timestamp": "2018-09-28T19:51:35Z"}

提取天气数据

从 IP 地址获取地理位置的问题已经在本章中得到了解决。

在本节的最后,我们将构建另一个将在以下章节中使用的提取器。现在,假设我们想了解在特定时间给定地理位置的当前温度。为了实现这一点,我们使用 OpenWeatherService。

访问 Open Weather 页面:openweathermap.org/

要获得一个免费的 API 密钥,请注册一个免费计划;此密钥用于访问免费 API。

现在,在 src/main/java/monedero/extractors 目录中创建一个名为 OpenWeatherService.java 的文件,其内容为 列表 3.7

package monedero.extractors;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URL;
import java.util.logging.Level;
import java.util.logging.Logger;
public class OpenWeatherService {
  private static final String API_KEY = "YOUR API_KEY_VALUE"; //1
  private static final ObjectMapper MAPPER = new ObjectMapper();
  public double getTemperature(String lat, String lon) {
    try {
      final URL url = new URL(
          "http://api.openweathermap.org/data/2.5/weather?lat=" + lat             + "&lon="+ lon +
          "&units=metric&appid=" + API_KEY); //2
      final JsonNode root = MAPPER.readTree(url);
      final JsonNode node = root.path("main").path("temp");/73
      return Double.parseDouble(node.toString());
    } catch (IOException ex) {
      Logger.getLogger(OpenWeatherService.class.getName()).log(Level.SEVERE, null, ex);
    }
    return 0;
  }
}

列表 3.7:OpenWeatherService.java

OpenWeatherService 类中的公共方法 getTemperature() 接收两个字符串值——地理纬度和经度,并返回这些位置的当前温度。如果指定了公制系统,则结果将以摄氏度为单位。

简而言之,该文件包括以下内容:

Open Weather 还通过 API 公开他们的服务。要了解如何使用此 API,请访问以下链接:

openweathermap.org/api

摘要

在本章中,我们介绍了如何进行数据提取,消息丰富化是如何工作的,以及如何根据 IP 地址提取地理位置。此外,我们还演示了如何根据货币和运行处理引擎提取货币价格的一个示例。

第四章,序列化,讨论了模式注册表。本章中构建的提取器将在以下章节中使用。

第四章:序列化

在现代(互联网)计算中,我们常常忘记实体必须从一台计算机传输到另一台计算机。为了能够传输实体,它们必须首先进行序列化。

序列化是将对象转换成字节流的过程,通常用于从一台计算机传输到另一台计算机。

如其名所示,反序列化是序列化的相反,即把字节流转换成对象(为了教学目的,我们可以说对象是被膨胀或再水化的),通常是从接收消息的一侧进行。Kafka 为原始数据类型(字节、整数、长整型、双精度浮点型、字符串等)提供了 Serializer/DeserializerSerDe)。

在本章中,介绍了一家新公司:Kioto(代表 Kafka 物联网)。本章涵盖了以下主题:

  • 如何构建 Java PlainProducer、消费者和处理器

  • 如何运行 Java PlainProducer 和处理器

  • 如何构建自定义序列化和反序列化器

  • 如何构建 Java CustomProducer、消费者和处理器

  • 如何运行 Java CustomProducer 和处理器

Kioto,一家 Kafka 物联网公司

Kioto 是一家致力于能源生产和分配的虚构公司。为了运营,Kioto 有几个 物联网IoT)设备。

Kioto 还想使用 Apache Kafka 构建一个企业服务总线。其目标是管理每分钟所有机器的物联网传感器接收到的所有消息。Kioto 在几个地点有数百台机器,每分钟向企业服务总线发送数千条不同的消息。

如前所述,Kioto 的机器上有大量的物联网设备,这些设备会持续向控制中心发送状态消息。这些机器产生电力,因此对于 Kioto 来说,确切知道机器的运行时间和它们的状态(运行、关闭、已关闭、启动等)非常重要。

京都也需要知道天气预报,因为一些机器在超过特定温度时不能运行。一些机器会根据环境温度表现出不同的行为。在冷环境中启动机器与在温暖环境中启动机器是不同的,因此在计算正常运行时间时,启动时间很重要。为了保证持续供电,信息必须精确。面对电力故障时,从温暖温度而不是从寒冷温度启动机器总是更好的。

列表 4.1 展示了以 JSON 格式的健康检查事件。

以下为 列表 4.1 的内容,healthcheck.json

{
   "event":"HEALTH_CHECK",
   "factory":"Duckburg",
   "serialNumber":"R2D2-C3P0",
   "type":"GEOTHERMAL",
   "status":"RUNNING",
   "lastStartedAt":"2017-09-04T17:27:28.747+0000",
   "temperature":31.5,
   "ipAddress":"192.166.197.213"}
}

列表 4.1: healthcheck.json

该消息在 JSON 中的提议表示具有以下属性:

  • event:消息类型的字符串(在这种情况下,HEALTH_CHECK

  • factory:工厂实际所在城市的名称

  • serialNumber:机器的序列号

  • type: 表示机器的类型,可能是GEOTHERMALHYDROELECTRICNUCLEARWINDSOLAR

  • status: 生命周期中的点:RUNNING, SHUTTING-DOWN, SHUT-DOWN, STARTING

  • lastStartedAt: 最后启动时间

  • temperature: 表示机器的温度,单位为摄氏度

  • ipAddress: 机器的 IP 地址

如我们所见,JSON 是一种人类可读的消息格式。

项目设置

第一步是创建 Kioto 项目。创建一个名为kioto的目录。进入该目录并执行以下命令:

$ gradle init --type java-library

输出类似于以下内容:

Starting a Gradle Daemon (subsequent builds will be faster)
BUILD SUCCESSFUL in 3s
2 actionable tasks: 2 execute BUILD SUCCESSFUL

Gradle 在目录中创建了一个默认项目,包括两个名为Library.javaLibraryTest.java的 Java 文件;删除这两个文件。

您的目录应类似于以下内容:

  • - build.gradle

  • - gradle

  • -- wrapper

  • --- gradle-wrapper.jar

  • --- gradle-vreapper.properties

  • - gradlew

  • - gradle.bat

  • - settings.gradle

  • - src

  • -- main

  • --- java

  • ----- Library.java

  • -- test

  • --- java

  • ----- LibraryTest.java

修改build.gradle文件,并用Listing 4.2替换它。

以下为Listing 4.2的内容,Kioto Gradle 构建文件:

apply plugin: 'java'
apply plugin: 'application'
sourceCompatibility = '1.8'
mainClassName = 'kioto.ProcessingEngine'
repositories {
    mavenCentral()
    maven { url 'https://packages.confluent.io/maven/' }
}
version = '0.1.0'
dependencies {
    compile 'com.github.javafaker:javafaker:0.15'
    compile 'com.fasterxml.jackson.core:jackson-core:2.9.7'
    compile 'io.confluent:kafka-avro-serializer:5.0.0'
    compile 'org.apache.kafka:kafka_2.12:2.0.0'
}
jar {
    manifest {
        attributes 'Main-Class': mainClassName
    } from {
        configurations.compile.collect {
            it.isDirectory() ? it : zipTree(it)
        }
    }
    exclude "META-INF/*.SF"
    exclude "META-INF/*.DSA"
    exclude "META-INF/*.RSA"
}

添加到应用程序的一些库依赖如下:

  • kafka_2.12,Apache Kafka 所需的依赖项

  • javafaker,JavaFaker 所需的依赖项

  • jackson-core,用于 JSON 解析和处理

  • kafka-avro-serializer,用于使用 Apache Avro 在 Kafka 中进行序列化

注意,为了使用kafka-avro-serializer函数,我们在仓库部分添加了 Confluent 仓库。

要编译项目并下载所需的依赖项,请输入以下命令:

$ gradle compileJava

输出应类似于以下内容:

BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed

项目也可以使用 Maven、SBT 或甚至从 IDE 创建。但为了简单起见,它使用 Gradle 创建。有关这些项目的更多信息,请访问以下链接:

常量

第一步是编写我们的Constants类。这个类是一个静态类,包含我们项目中需要的所有Constants

使用您喜欢的 IDE 打开项目,并在src/main/java/kioto目录下创建一个名为Constants.java的文件,其内容为Listing 4.3

以下为Listing 4.3的内容,Constants.java

package kioto;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.util.StdDateFormat;
public final class Constants {
  private static final ObjectMapper jsonMapper;
  static {
    ObjectMapper mapper = new ObjectMapper();
    mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    mapper.setDateFormat(new StdDateFormat());
    jsonMapper = mapper;
  }
  public static String getHealthChecksTopic() {
    return "healthchecks";
  }
  public static String getHealthChecksAvroTopic() {
    return "healthchecks-avro";
  }
  public static String getUptimesTopic() {
    return "uptimes";
  }
  public enum machineType {GEOTHERMAL, HYDROELECTRIC, NUCLEAR, WIND, SOLAR}
  public enum machineStatus {STARTING, RUNNING, SHUTTING_DOWN, SHUT_DOWN}
  public static ObjectMapper getJsonMapper() {
    return jsonMapper;
  }
}

在我们的Constants类中,有一些我们稍后会用到的方法。如下所示:

  • getHealthChecksTopic: 返回健康检查输入主题的名称

  • getHealthChecksAvroTopic: 返回包含健康检查的 Avro 主题的名称

  • getUptimesTopic:它返回 uptimes 主题的名称

  • machineType:这是一个 enum,包含 Kioto 产生能源机器的类型

  • machineType:这是一个 enum,包含 Kioto 机器可能的类型

  • getJsonMapper:它返回用于 JSON 序列化的对象映射器,并且我们设置了日期的序列化格式

这是一个 Constants 类;在 Kotlin 等语言中,常量不需要独立的类,但我们使用 Java。一些面向对象编程的纯粹主义者认为编写常量类是面向对象的反模式。然而,为了简单起见,我们需要在我们的系统中有一些常量。

健康检查消息

第二步是编写 HealthCheck 类。这个类是一个 普通的 Java 对象POJO)。model 类是值对象的模板。

使用您最喜欢的 IDE 打开项目,在 src/main/java/kioto 目录下,创建一个名为 HealthCheck.java 的文件,其内容为 列表 4.4

下面的内容是 列表 4.4HealthCheck.java 的内容:

package kioto;
import java.util.Date;
public final class HealthCheck {
  private String event;
  private String factory;
  private String serialNumber;
  private String type;
  private String status;
  private Date lastStartedAt;
  private float temperature;
  private String ipAddress;
}

列表 4.4:HealthCheck.java

使用您的 IDE,生成以下内容:

  • 一个无参数构造函数

  • 一个带有所有属性作为参数的构造函数

  • 每个属性的获取器和设置器

这是一个数据类,Java 中的 POJO。在 Kotlin 等语言中,模型类需要的样板代码要少得多,但现在我们是在 Java 中。一些面向对象编程的纯粹主义者认为值对象是面向对象的反模式。然而,用于生成消息的序列化库需要这些类。

要使用 JavaFaker 生成假数据,我们的代码应如 列表 4.5 所示。

下面的内容是 列表 4.5,一个使用 JavaFaker 的健康检查模拟生成器:

HealthCheck fakeHealthCheck =
   new HealthCheck(
        "HEALTH_CHECK",
        faker.address().city(),                    //1
        faker.bothify("??##-??##", true),    //2
              Constants.machineType.values()
                   [faker.number().numberBetween(0,4)].toString(), //3
        Constants.machineStatus.values()
                   [faker.number().numberBetween(0,3)].toString(), //4
        faker.date().past(100, TimeUnit.DAYS),           //5
        faker.number().numberBetween(100L, 0L),          //6
        faker.internet().ipV4Address());                 //7

下面的内容是对如何生成假健康检查数据的分析:

  • 在第 //1 行,address().city() 生成一个虚构的城市名称

  • 在第 //2 行,在表达式 ? 用于字母,# 用于数字,如果字母是大写则为 true

  • 在第 //3 行,我们使用 Constants 中的机器类型 enum,以及一个介于 04 之间的假数字

  • 在第 //4 行,我们使用 Constants 中的机器状态 enum,以及一个介于 03 之间的假数字,包括 3

  • 在第 //5 行,我们表示我们想要一个从今天起过去 100 天的假日期

  • 在第 //6 行,我们构建一个假 IP 地址

这里,我们依赖于构造函数的属性顺序。其他语言,如 Kotlin,允许指定每个分配的属性名称。

现在,要将我们的 Java POJO 转换为 JSON 字符串,我们使用 Constants 类中的方法——类似于以下内容:

String fakeHealthCheckJson fakeHealthCheckJson = Constants.getJsonMapper().writeValueAsString(fakeHealthCheck);

不要忘记这个方法会抛出一个 JSON 处理异常。

Java 纯生产者

如我们所知,要构建一个 Kafka 消息生产者,我们使用 Java 客户端库,特别是生产者 API(在接下来的章节中,我们将看到如何使用 Kafka Streams 和 KSQL)。

我们首先需要的是一个数据源;为了简单起见,我们需要生成我们的模拟数据。每条消息都将是一条健康消息,包含所有其属性。第一步是构建一个生产者,以 JSON 格式将这些消息发送到主题,如下例所示:

{"event":"HEALTH_CHECK","factory":"Port Roelborough","serialNumber":"QT89-TZ50","type":"GEOTHERMAL","status":"SHUTTING_DOWN","lastStartedAt":"2018-09-13T00:36:39.079+0000","temperature":28.0,"ipAddress":"235.180.238.3"}

{"event":"HEALTH_CHECK","factory":"Duckburg","serialNumber":"NB49-XL51","type":"NUCLEAR","status":"RUNNING","lastStartedAt":"2018-08-18T05:42:29.648+0000","temperature":49.0,"ipAddress":"42.181.105.188"}
...

让我们先创建一个 Kafka 生产者,我们将使用它来发送输入消息。

如我们所知,所有 Kafka 生产者都应该有两个必备条件:它们必须是KafkaProducer,并且设置了特定的属性,如列表 4.6所示。

以下为列表 4.6的内容,PlainProducer的构造方法:

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.common.serialization.StringSerializer;
public final class PlainProducer {
  private final Producer<String, String> producer;
  public PlainProducer(String brokers) {
    Properties props = new Properties();
    props.put("bootstrap.servers", brokers);                //1
    props.put("key.serializer", StringSerializer.class);    //2
    props.put("value.serializer", StringSerializer.class);  //3
    producer = new KafkaProducer<>(props);                  //4
  }
  ...
}

PlainProducer构造函数的分析包括以下内容:

  • 在第//1行,我们的生产者将运行的代理列表

  • 在第//2行,消息键的序列化类型(我们稍后会看到序列化器)

  • 在第//3行,消息值的序列化类型(在这种情况下,值是字符串)

  • 在第//4行,使用这些属性构建一个具有字符串键和字符串值的KafkaProducer,例如<String, String>

  • 注意,属性的行为类似于 HashMap;在像 Kotlin 这样的语言中,可以使用=运算符而不是调用方法来执行属性赋值

我们使用字符串序列化器来序列化键和值:在这个第一个方法中,我们将手动使用 Jackson 将值序列化为 JSON。我们稍后会看到如何编写自定义序列化器。

现在,在src/main/java/kioto/plain目录下,创建一个名为PlainProducer.java的文件,内容为列表 4.7

以下为列表 4.7的内容,PlainProducer.java

package kioto.plain;
import ...
public final class PlainProducer {
  /* here the Constructor code in Listing 4.6 */
  public void produce(int ratePerSecond) {
    long waitTimeBetweenIterationsMs = 1000L / (long)ratePerSecond; //1
    Faker faker = new Faker();
    while(true) { //2
      HealthCheck fakeHealthCheck /* here the code in Listing 4.5 */;
      String fakeHealthCheckJson = null;
      try {
        fakeHealthCheckJson = Constants.getJsonMapper().writeValueAsString(fakeHealthCheck); //3
      } catch (JsonProcessingException e) {
         // deal with the exception
      }
      Future futureResult = producer.send(new ProducerRecord<>
         (Constants.getHealthChecksTopic(), fakeHealthCheckJson)); //4
      try {
        Thread.sleep(waitTimeBetweenIterationsMs); //5
        futureResult.get(); //6
      } catch (InterruptedException | ExecutionException e) {
         // deal with the exception
      }
    }
  }
  public static void main(String[] args) {
    new PlainProducer("localhost:9092").produce(2); //7
  }
}

PlainProducer类的分析包括以下内容:

  • 在第//1行,ratePerSecond是在一秒内要发送的消息数量

  • 在第//2行,为了模拟重复,我们使用无限循环(在生产环境中尽量避免这样做)

  • 在第//3行,将 Java POJO 序列化为 JSON 的代码

  • 在第//4行,我们使用 Java Future 将消息发送到HealthChecksTopic

  • 在第//5行,这次我们等待再次发送消息

  • 在第//6行,我们读取之前创建的 future 的结果

  • 在第//7行,所有操作都在本地的9092端口上的代理上运行,每隔一秒发送两条消息

重要的是要注意,在这里我们发送没有键的记录;我们只指定了值(一个 JSON 字符串),因此键是null。我们还对结果调用了get()方法,以便等待写入确认:没有这个,消息可能会发送到 Kafka,但如果没有我们的程序注意到失败,消息可能会丢失。

运行 PlainProducer

要构建项目,从kioto目录运行以下命令:

$ gradle jar

如果一切正常,输出如下:

BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed
  1. 从命令行终端,移动到confluent目录,并按以下方式启动:
$ ./bin/confluent start
  1. 代理正在9092端口上运行。要创建healthchecks主题,执行以下命令:
$ ./bin/kafka-topics --zookeeper localhost:2181 --create --topic             
healthchecks --replication-factor 1 --partitions 4
  1. 通过键入以下内容运行healthchecks主题的控制台消费者:
$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092       
--topic healthchecks
  1. 从我们的 IDE 中运行PlainProducermain方法

  2. 控制台消费者的输出应类似于以下内容:

{"event":"HEALTH_CHECK","factory":"Lake Anyaport","serialNumber":"EW05-HV36","type":"WIND","status":"STARTING","lastStartedAt":"2018-09-17T11:05:26.094+0000","temperature":62.0,"ipAddress":"15.185.195.90"}

{"event":"HEALTH_CHECK","factory":"Candelariohaven","serialNumber":"BO58-SB28","type":"SOLAR","status":"STARTING","lastStartedAt":"2018-08-16T04:00:00.179+0000","temperature":75.0,"ipAddress":"151.157.164.162"}

{"event":"HEALTH_CHECK","factory":"Ramonaview","serialNumber":"DV03-ZT93","type":"SOLAR","status":"RUNNING","lastStartedAt":"2018-07-12T10:16:39.091+0000","temperature":70.0,"ipAddress":"173.141.90.85"}
...

记住,在产生数据时,我们可以实现几种写入保证。

例如,在出现网络故障或代理故障的情况下,我们的系统是否准备好丢失数据?

在三个因素之间存在着权衡:产生消息的可用性、生产中的延迟以及安全写入的保证。

在这个例子中,我们只有一个代理,我们使用默认的acks值为 1。当我们将来调用get()方法时,我们正在等待代理的确认,也就是说,我们在发送另一条消息之前确保消息已持久化。在这个配置中,我们不丢失消息,但我们的延迟比在 fire and forget 模式中更高。

Java 普通消费者

正如我们已知的,要构建一个 Kafka 消息消费者,我们使用 Java 客户端库——特别是消费者 API(在接下来的章节中,我们将看到如何使用 Kafka Streams 和 KSQL)。

让我们创建一个 Kafka 消费者,我们将使用它来接收输入消息。

正如我们已知的,所有 Kafka 消费者都应该有两个必备条件:成为一个KafkaConsumer并设置特定的属性,例如列表 4.8中所示。

以下为列表 4.8的内容,普通消费者的构造函数:

import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.common.serialization.StringSerializer;
public final class PlainConsumer {
  private Consumer<String, String> consumer;
  public PlainConsumer(String brokers) {
    Properties props = new Properties();
    props.put("group.id", "healthcheck-processor");         //1
    props.put("bootstrap.servers", brokers);                   //2
    props.put("key.deserializer", StringDeserializer.class);   //3
    props.put("value.deserializer", StringDeserializer.class); //4
    consumer = new KafkaConsumer<>(props);                        //5
  }
  ...
}

对普通消费者构造函数的分析包括以下内容:

  • 在行//1中,消费者的组 ID,在这种情况下,healthcheck-processor

  • 在行//2中,消费者将运行的brokers列表

  • 在行//3中,消息键的解序列化类型(我们将在后面看到解序列化器)

  • 在行//4中,消息值的解序列化类型,在这种情况下,值是字符串

  • 在行//5中,使用这些属性,我们构建了一个具有字符串键和字符串值的KafkaConsumer,例如<String, String>

对于客户来说,我们需要提供一个组 ID 来指定消费者将加入的消费者组。

在多个消费者并行启动的情况下,无论是通过不同的线程还是通过不同的进程,每个消费者都将被分配到主题分区的一个子集。在我们的例子中,我们创建了具有四个分区的主题,这意味着为了并行消费数据,我们可以创建多达四个消费者。

对于消费者,我们提供解序列化器而不是序列化器。尽管我们不使用键解序列化器(因为如果你记得,它是null),但键解序列化器是消费者指定的强制参数。另一方面,我们需要值的解序列化器,因为我们正在以 JSON 字符串的形式读取我们的数据,而在这里我们使用 Jackson 手动解序列化对象。

Java 普通处理器

现在,在src/main/java/kioto/plain目录下,创建一个名为PlainProcessor.java的文件,内容为列表 4.9

下面的内容是列表 4.9PlainProcessor.java(第一部分):

package kioto.plain;
import ...
public final class PlainProcessor {
  private Consumer<String, String> consumer;
  private Producer<String, String> producer;
  public PlainProcessor(String brokers) {
    Properties consumerProps = new Properties();
    consumerProps.put("bootstrap.servers", brokers);
    consumerProps.put("group.id", "healthcheck-processor");
    consumerProps.put("key.deserializer", StringDeserializer.class);
    consumerProps.put("value.deserializer", StringDeserializer.class);
    consumer = new KafkaConsumer<>(consumerProps);
    Properties producerProps = new Properties();
    producerProps.put("bootstrap.servers", brokers);
    producerProps.put("key.serializer", StringSerializer.class);
    producerProps.put("value.serializer", StringSerializer.class);
    producer = new KafkaProducer<>(producerProps);
  }

PlainProcessor类第一部分的解析包括以下内容:

  • 在第一部分,我们声明了一个消费者,如列表 4.8所示

  • 在第二部分,我们声明了一个生产者,如列表 4.6所示

在继续编写代码之前,让我们记住 Kioto 流处理引擎的项目需求。

将所有这些放在一起,规范是要创建一个流引擎,执行以下操作:

  • 向名为healthchecks的 Kafka 主题生成消息

  • 从名为healthchecks的 Kafka 主题读取消息

  • 根据启动时间计算正常运行时间

  • 将消息写入名为uptimes的 Kafka 主题

整个过程在图 4.1中有详细说明,即 Kioto 流处理应用程序:

图 4.1:消息被生成到 HealthChecksTopic,然后读取,最后将计算出的正常运行时间写入 uptimes 主题。

现在我们处于src/main/java/kioto/plain目录中,让我们用列表 4.10的内容完成PlainProcessor.java文件。

下面的内容是列表 4.10PlainProcessor.java(第二部分):

 public final void process() {
    consumer.subscribe(Collections.singletonList(
               Constants.getHealthChecksTopic()));           //1
    while(true) {
      ConsumerRecords records = consumer.poll(Duration.ofSeconds(1L)); //2
      for(Object record : records) {                //3
        ConsumerRecord it = (ConsumerRecord) record;
        String healthCheckJson = (String) it.value();
        HealthCheck healthCheck = null;
        try {
          healthCheck = Constants.getJsonMapper()
           .readValue(healthCheckJson, HealthCheck.class);     // 4
        } catch (IOException e) {
            // deal with the exception
        }
        LocalDate startDateLocal =healthCheck.getLastStartedAt().toInstant()                   .atZone(ZoneId.systemDefault()).toLocalDate();        //5
        int uptime =
             Period.between(startDateLocal, LocalDate.now()).getDays();  //6
        Future future =
             producer.send(new ProducerRecord<>(
                              Constants.getUptimesTopic(),
                              healthCheck.getSerialNumber(),
                              String.valueOf(uptime)));                  //7
        try {
          future.get();
        } catch (InterruptedException | ExecutionException e) {
          // deal with the exception
        }
      }
    }
  }
  public static void main( String[] args) {
    (new PlainProcessor("localhost:9092")).process();
  }
}

列表 4.10:PlainProcessor.java(第二部分)

PlainProcessor的分析包括以下内容:

  • 在第//1行,创建了消费者并订阅了源主题。这是将分区动态分配给我们的客户并加入客户组的操作。

  • 在第//2行,一个无限循环用于消费记录,将池持续时间作为参数传递给方法池。客户在返回前最多等待一秒钟。

  • 在第//3行,我们遍历记录。

  • 在第//4行,将 JSON 字符串反序列化以提取健康检查对象。

  • 在第//5行,开始时间被转换并格式化为当前时区。

  • 在第//6行,计算正常运行时间。

  • 在第//7行,使用序列号作为键和正常运行时间作为值,将正常运行时间写入uptimes主题。两个值都作为普通字符串写入。

代理将记录返回给客户端的时刻也取决于fetch.min.bytes值;其默认值为 1,是等待代理对客户端可用之前的最小数据量。我们的代理在 1 字节数据可用时立即返回,同时最多等待一秒钟。

另一个配置属性是fetch.max.bytes,它定义了一次返回的数据量。根据我们的配置,代理将返回所有可用的记录(不超过 50 MB 的最大值)。

如果没有可用的记录,代理返回一个空列表。

注意,我们可以重用生成模拟数据的代理,但使用另一个代理来写入uptimes更清晰。

运行 PlainProcessor

要构建项目,从kioto目录运行以下命令:

$ gradle jar

如果一切正确,输出将类似于以下内容:

BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed
  1. 我们的代理正在端口9092上运行,因此要创建uptimes主题,执行以下命令:
$ ./bin/kafka-topics --zookeeper localhost:2181 --create --topic 
uptimes --replication-factor 1 --partitions 4
  1. 运行uptimes主题的控制台消费者,如下所示:
$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 
--topic uptimes --property print.key=true
  1. 从我们的 IDE 中运行PlainProcessor的 main 方法

  2. 从我们的 IDE 中运行PlainProducer的 main 方法

  3. 控制台消费者对于uptimes主题的输出应类似于以下内容:

EW05-HV36   33
BO58-SB28   20
DV03-ZT93   46
...

我们已经说过,在产生数据时,有两个因素需要考虑;一个是交付保证,另一个是分区。

在消费数据时,我们必须考虑以下四个因素:

  • 并行运行的消费者数量(并行线程和/或并行进程)

  • 一次要消费的数据量(从内存的角度考虑)

  • 等待接收消息的时间(吞吐量和延迟)

  • 何时标记消息为已处理(提交偏移量)

如果enable.auto.commit设置为true(默认为true),消费者将在下一次调用 poll 方法时自动提交偏移量。

注意,整个批次记录都会被提交;如果处理了一些消息但未处理整个批次后应用程序崩溃,则事件不会被提交,它们将被其他消费者重新处理;这种处理数据的方式称为至少一次处理。

自定义序列化器

到目前为止,我们已经看到了如何使用纯 Java 和 Jackson 产生和消费 JSON 消息。在这里,我们将看到如何创建我们自己的序列化器和反序列化器。

我们已经看到了如何在生产者中使用StringSerializer,在消费者中使用StringDeserializer。现在,我们将看到如何构建我们自己的 SerDe,以将序列化/反序列化过程从应用程序的核心代码中抽象出来。

要构建自定义序列化器,我们需要创建一个实现org.apache.kafka.common.serialization.Serializer接口的类。这是一个泛型类型,因此我们可以指定要转换为字节数组的自定义类型(序列化)。

src/main/java/kioto/serde目录下,创建一个名为HealthCheckSerializer.java的文件,其内容为列表 4.11

以下为列表 4.11HealthCheckSerializer.java的内容:

package kioto.serde;
import com.fasterxml.jackson.core.JsonProcessingException;
import kioto.Constants;
import java.util.Map;
import org.apache.kafka.common.serialization.Serializer;
public final class HealthCheckSerializer implements Serializer {
  @Override
  public byte[] serialize(String topic, Object data) {
    if (data == null) {
      return null;
    }
    try {
      return Constants.getJsonMapper().writeValueAsBytes(data);
    } catch (JsonProcessingException e) {
      return null;
    }
  }

  @Override
  public void close() {}
  @Override
  public void configure(Map configs, boolean isKey) {}
}

列表 4.11:HealthCheckSerializer.java

注意,序列化器类位于名为kafka-clients的特殊模块中,位于org.apache.kafka路径。这里的目的是使用序列化器类而不是 Jackson(手动)。

还要注意,需要实现的重要方法是serialize方法。closeconfigure方法可以留空体。

我们导入 Jackson 的JsonProcessingException只是因为writeValueAsBytes方法会抛出这个异常,但我们不使用 Jackson 进行序列化。

Java 自定义生产者

现在,为了将序列化器纳入我们的生产者,所有 Kafka 生产者都必须满足两个要求:成为一个KafkaProducer,并设置特定的属性,如列表 4.12

以下为列表 4.12CustomProducer的构造方法内容:

import kioto.serde.HealthCheckSerializer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.common.serialization.StringSerializer;
public final class CustomProducer {
  private final Producer<String, HealthCheck> producer;
  public CustomProducer(String brokers) {
    Properties props = new Properties();
    props.put("bootstrap.servers", brokers);                    //1
    props.put("key.serializer", StringSerializer.class);        //2
    props.put("value.serializer", HealthCheckSerializer.class); //3
    producer = new KafkaProducer<>(props);                      //4
  }

CustomProducer构造函数的分析包括以下内容:

  • 在行//1中,这是我们的生产者将运行在的代理列表。

  • 在行//2中,消息键的序列化器类型保持为字符串。在行//3中,这是消息值的序列化器类型,在这种情况下,值是HealthCheck

  • 在行//4中,我们使用这些属性构建了一个带有字符串键和HealthCheck值的KafkaProducer,例如,<String, HealthCheck>

现在,在src/main/java/kioto/custom目录中,创建一个名为CustomProducer.java的文件,内容为列表 4.13

以下为列表 4.13CustomProducer.java的内容:

package kioto.plain;
import ...
public final class CustomProducer {
  /* here the Constructor code in Listing 4.12 */
  public void produce(int ratePerSecond) {
    long waitTimeBetweenIterationsMs = 1000L / (long)ratePerSecond; //1
    Faker faker = new Faker();
    while(true) { //2
      HealthCheck fakeHealthCheck /* here the code in Listing 4.5 */;
      Future futureResult = producer.send( new ProducerRecord<>(
         Constants.getHealthChecksTopic(), fakeHealthCheck));       //3
      try {
        Thread.sleep(waitTimeBetweenIterationsMs); //4
        futureResult.get();      //5          
      } catch (InterruptedException | ExecutionException e) {
        // deal with the exception
      }
    }
  }
public static void main(String[] args) {
    new CustomProducer("localhost:9092").produce(2); //6
  }
}

列表 4.13: CustomProducer.java

CustomProducer类的分析包括以下内容:

  • 在行//1中,ratePerSecond是一秒内要发送的消息数量

  • 在行//2中,为了模拟重复,我们使用了一个无限循环(在生产环境中尽量避免这样做)

  • 在行//3中,我们使用 Java future 将消息发送到HealthChecksTopic

  • 在行//4中,这次我们等待再次发送消息

  • 在行//5中,我们读取之前创建的 future 的结果

  • 在行//6中,所有操作都在本地的9092端口上的代理上运行,以一秒的间隔发送两条消息

运行 CustomProducer

要构建项目,请在kioto目录中运行以下命令:

$ gradle jar

如果一切正常,输出将类似于以下内容:

BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed
  1. 按如下方式运行HealthChecksTopic的控制台消费者:
$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 
--topic healthchecks
  1. 从我们的 IDE 中,运行CustomProducermain方法

  2. 控制台消费者的输出应类似于以下内容:

{"event":"HEALTH_CHECK","factory":"Lake Anyaport","serialNumber":"EW05-HV36","type":"WIND","status":"STARTING","lastStartedAt":"2018-09-17T11:05:26.094+0000","temperature":62.0,"ipAddress":"15.185.195.90"}

{"event":"HEALTH_CHECK","factory":"Candelariohaven","serialNumber":"BO58-SB28","type":"SOLAR","status":"STARTING","lastStartedAt":"2018-08-16T04:00:00.179+0000","temperature":75.0,"ipAddress":"151.157.164.162"}

{"event":"HEALTH_CHECK","factory":"Ramonaview","serialNumber":"DV03-ZT93","type":"SOLAR","status":"RUNNING","lastStartedAt":"2018-07-12T10:16:39.091+0000","temperature":70.0,"ipAddress":"173.141.90.85"}

...

自定义反序列化器

以类似的方式,要构建自定义反序列化器,我们需要创建一个实现org.apache.kafka.common.serialization.Deserializer接口的类。我们必须指明如何将字节数组转换为自定义类型(反序列化)。

src/main/java/kioto/serde目录中,创建一个名为HealthCheckDeserializer.java的文件,内容为列表 4.14

以下为列表 4.14HealthCheckDeserializer.java的内容:

package kioto.serde;
import kioto.Constants;
import kioto.HealthCheck;
import java.io.IOException;
import java.util.Map;
import org.apache.kafka.common.serialization.Deserializer;
public final class HealthCheckDeserializer implements Deserializer {
  @Override
  public HealthCheck deserialize(String topic, byte[] data) {
    if (data == null) {
      return null;
    }
    try {
      return Constants.getJsonMapper().readValue(data, HealthCheck.class);
    } catch (IOException e) {
      return null;
    }
  }
  @Override
  public void close() {}
  @Override
  public void configure(Map configs, boolean isKey) {}
}

列表 4.14: HealthCheckDeserializer.java

注意,反序列化器类位于名为 kafka-clients 的模块中,位于org.apache.kafka路径下。这里的目的是使用反序列化器类而不是 Jackson(手动)。

还要注意,需要实现的重要方法是deserialize方法。closeconfigure方法可以留空。

我们导入HealthCheck类,因为readValue方法需要一个 POJO(具有公共构造函数和公共 getter 和 setter 的类)。注意,所有 POJO 属性都应该是可序列化的。

Java 自定义消费者

让我们创建一个 Kafka 消费者,我们将使用它来接收自定义输入消息。

现在,为了在我们的消费者中包含反序列化器,所有 Kafka 消费者都应该满足两个要求:成为一个KafkaConsumer,并设置特定的属性,如列表 4.15中所示。

以下为列表 4.15的内容,CustomConsumer的构造方法:

import kioto.HealthCheck;
import kioto.serde.HealthCheckDeserializer;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.common.serialization.StringSerializer;
public final class CustomConsumer {
  private Consumer<String, HealthCheck> consumer;
  public CustomConsumer(String brokers) {
    Properties props = new Properties();
    props.put("group.id", "healthcheck-processor");//1
    props.put("bootstrap.servers", brokers);//2
    props.put("key.deserializer", StringDeserializer.class);//3
    props.put("value.deserializer", HealthCheckDeserializer.class); //4
    consumer = new KafkaConsumer<>(props);//5
  }
  ...
}

CustomConsumer构造函数的以下分析:

  • 在行//1中,我们消费者的组 ID,在这种情况下,healthcheck-processor

  • 在行//2中,消费者将运行在其上的代理列表

  • 在行//3中,消息键的反序列化类型;在这种情况下,键保持为字符串

  • 在行//4中,消息值的反序列化类型;在这种情况下,值是HealthChecks

  • 在行//5中,使用这些属性,我们构建了一个具有字符串键和HealthChecks值的KafkaConsumer,例如<String, HealthCheck>

对于消费者,我们提供反序列化器而不是序列化器。尽管我们不使用键反序列化器(因为如果你记得,它是null),但键反序列化器是消费者指定的强制参数。另一方面,我们需要值反序列化器,因为我们正在以 JSON 字符串的形式读取我们的数据;在这里,我们使用自定义反序列化器反序列化对象。

Java 自定义处理器

现在,在src/main/java/kioto/custom目录下,创建一个名为CustomProcessor.java的文件,其内容为列表 4.16

以下为列表 4.16的内容,CustomProcessor.java(第一部分):

package kioto.custom;
import ...

public final class CustomProcessor {

  private Consumer<String, HealthCheck> consumer;
  private Producer<String, String> producer;

  public CustomProcessor(String brokers) {
    Properties consumerProps = new Properties();
    consumerProps.put("bootstrap.servers", brokers);
    consumerProps.put("group.id", "healthcheck-processor");
    consumerProps.put("key.deserializer", StringDeserializer.class);
    consumerProps.put("value.deserializer",                        HealthCheckDeserializer.class);
    consumer = new KafkaConsumer<>(consumerProps);
    Properties producerProps = new Properties();
    producerProps.put("bootstrap.servers", brokers);
    producerProps.put("key.serializer", StringSerializer.class);
    producerProps.put("value.serializer", StringSerializer.class);
    producer = new KafkaProducer<>(producerProps);
  }

对自定义处理器类第一部分的分析如下:

  • 在第一部分,我们声明了一个消费者,如列表 4.15所示

  • 在第二部分,我们声明了一个生产者,如列表 4.13所示

现在,在src/main/java/kioto/custom目录下,让我们用列表 4.17的内容完成CustomProcessor.java文件。

以下为列表 4.17的内容,CustomProcessor.java(第二部分):

public final void process() {
    consumer.subscribe(Collections.singletonList(
             Constants.getHealthChecksTopic()));           //1
    while(true) {
      ConsumerRecords records = consumer.poll(Duration.ofSeconds(1L)); //2
      for(Object record : records) {                 //3
        ConsumerRecord it = (ConsumerRecord) record;
        HealthCheck healthCheck = (HealthCheck) it.value(); //4
        LocalDate startDateLocal =healthCheck.getLastStartedAt().toInstant()
                 .atZone(ZoneId.systemDefault()).toLocalDate();         //5
        int uptime =
             Period.between(startDateLocal, LocalDate.now()).getDays();  //6
        Future future =
             producer.send(new ProducerRecord<>(
                              Constants.getUptimesTopic(),
                              healthCheck.getSerialNumber(),
                             String.valueOf(uptime)));                  //7
        try {
          future.get();
        } catch (InterruptedException | ExecutionException e) {
          // deal with the exception
        }
      }
    }
  }
  public static void main( String[] args) {
    new CustomProcessor("localhost:9092").process();
  }
}

CustomProcessor处理方法的以下分析:

  • 在行//1中,这里创建了消费者并订阅了源主题。这是将分区动态分配给我们的客户并加入客户组的操作。

  • 在行//2中,一个无限循环来消费记录,将池持续时间作为参数传递给方法池。客户在返回之前不会等待超过一秒钟。

  • 在行//3中,我们遍历记录。

  • 在行//4中,将 JSON 字符串反序列化以提取HealthCheck对象。

  • 在行//5中,将开始时间转换为当前时区的格式。

  • 在行//6中,计算了运行时间。

  • 在行//7中,使用序列号作为键,将运行时间(uptime)写入uptimes主题,这两个值都作为普通字符串写入。

运行自定义处理器

要构建项目,请从kioto目录中运行以下命令:

$ gradle jar

如果一切正常,输出将类似于以下内容:

BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed
  1. 按照以下方式运行uptimes主题的控制台消费者:
$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 
--topic uptimes --property print.key=true
  1. 从我们的集成开发环境(IDE)中运行CustomProcessor的主方法

  2. 从我们的 IDE 中运行CustomProducer的主方法

  3. 控制台消费者对于uptimes主题的输出应类似于以下内容:

EW05-HV36   33
BO58-SB28   20
DV03-ZT93   46
...

现在,我们已经看到了如何创建我们自己的 SerDe,以将序列化代码从应用程序的主要逻辑中抽象出来。现在你知道了 Kafka SerDe 是如何工作的。

摘要

在本章中,我们学习了如何构建 Java PlainProducer、消费者和处理器,并展示了如何构建自定义序列化和反序列化器。

此外,我们还学习了如何构建 Java CustomProducer、消费者和处理器,以及如何运行 Java CustomProducer 和处理器。

在本章中,我们看到了如何使用 JSON、纯文本和二进制格式通过 Kafka 进行序列化和反序列化。Avro 是 Kafka 的常见序列化类型。我们将在第五章 模式注册表中看到如何使用 Avro,以及如何使用 Kafka 模式注册表。

第五章:Schema Registry

在上一章中,我们看到了如何以 JSON 格式生产和消费数据。在本章中,我们将了解如何使用 Apache Avro 序列化相同的消息。

本章涵盖了以下主题:

  • Avro 简要介绍

  • 定义模式

  • 启动 Schema Registry

  • 使用 Schema Registry

  • 如何构建 Java AvroProducer、消费者和处理器

  • 如何运行 Java AvroProducer 和处理器

Avro 简要介绍

Apache Avro 是一种二进制序列化格式。该格式基于模式,因此它依赖于 JSON 格式中的模式定义。这些模式定义了哪些字段是必需的以及它们的类型。Avro 还支持数组、枚举和嵌套字段。

Avro 的一个主要优点是它支持模式演进。这样,我们可以拥有几个模式的历史版本。

通常,系统必须适应业务的变化需求。因此,我们可以向我们的实体添加或删除字段,甚至更改数据类型。为了支持向前或向后兼容性,我们必须考虑哪些字段被标记为可选的。

因为 Avro 将数据转换为字节数组(序列化),而 Kafka 的消息也是以二进制数据格式发送的,所以使用 Apache Kafka,我们可以以 Avro 格式发送消息。真正的问题是,我们将在哪里存储 Apache Avro 的工作模式?

回想一下,企业服务总线的主要功能之一是验证它所处理的消息的格式,如果它有这些格式的历史记录会更好。

Kafka Schema Registry 是负责执行重要功能的模块。第一个功能是验证消息是否处于适当的格式,第二个功能是拥有这些模式的存储库,第三个功能是拥有这些模式的历史版本格式。

Schema Registry 是一个与我们的 Kafka 代理在同一位置运行的服务器。它运行并存储模式,包括模式版本。当以 Avro 格式将消息发送到 Kafka 时,消息包含一个存储在 Schema Registry 中的模式标识符。

有一个库允许在 Avro 格式中进行消息序列化和反序列化。这个库与 Schema Registry 透明且自然地协同工作。

当以 Avro 格式发送消息时,序列化器确保已注册模式并获取模式 ID。如果我们发送一个不在 Schema Registry 中的 Avro 消息,当前版本的模式将自动在注册表中注册。如果您不希望 Schema Registry 以这种方式操作,可以通过将 auto.register.schemas 标志设置为 false 来禁用它。

当接收到 Avro 格式的消息时,反序列化器试图在注册表中找到模式 ID 并获取模式以反序列化 Avro 格式的消息。

模式注册表以及用于 Avro 格式消息序列化和反序列化的库都在 Confluent 平台下。重要的是要提到,当您需要使用模式注册表时,您必须使用 Confluent 平台。

还很重要的一点是,使用模式注册表时,应该使用 Confluent 库进行 Avro 格式的序列化,因为 Apache Avro 库不起作用。

定义模式

第一步是定义 Avro 模式。作为提醒,我们的HealthCheck类看起来像列表 5.1

public final class HealthCheck {
 private String event;
 private String factory;
 private String serialNumber;
 private String type;
 private String status;
 private Date lastStartedAt;
 private float temperature;
 private String ipAddress;
}

列表 5.1: HealthCheck.java

现在,有了这个消息的 Avro 格式表示,所有此类消息的 Avro 模式(即模板)将是列表 5.2

{
 "name": "HealthCheck",
 "namespace": "kioto.avro",
 "type": "record",
 "fields": [
 { "name": "event", "type": "string" },
 { "name": "factory", "type": "string" },
 { "name": "serialNumber", "type": "string" },
 { "name": "type", "type": "string" },
 { "name": "status", "type": "string"},
 { "name": "lastStartedAt", "type": "long", "logicalType": "timestamp-
    millis"},
 { "name": "temperature", "type": "float" },
 { "name": "ipAddress", "type": "string" }
 ]
}

列表 5.2: healthcheck.avsc

此文件必须保存在kioto项目的src/main/resources目录下。

重要的是要注意,有stringfloatdouble这些类型。但是,对于Date,它可以存储为longstring

对于这个例子,我们将Date序列化为long。Avro 没有专门的Date类型;我们必须在longstring(通常是 ISO-8601 的string)之间选择,但这个例子中的重点是展示如何使用不同的数据类型。

更多关于 Avro 模式和如何映射类型的信息,请查看以下 URL 的 Apache Avro 规范:

avro.apache.org/docs/current/spec.html

启动模式注册表

好吧,我们已经有了我们的 Avro 模式;现在,我们需要在模式注册表中注册它。

当我们启动 Confluent 平台时,模式注册表也会启动,如下面的代码所示:

$./bin/confluent start
Starting zookeeper
zookeeper is [UP]
Starting kafka
kafka is [UP]
Starting schema-registry
schema-registry is [UP]
Starting kafka-rest
kafka-rest is [UP]
Starting connect
connect is [UP]
Starting ksql-server
ksql-server is [UP]
Starting control-center
control-center is [UP]

如果我们只想启动模式注册表,我们需要运行以下命令:

$./bin/schema-registry-start etc/schema-registry/schema-registry.properties

输出类似于这里所示:

...
[2017-03-02 10:01:45,320] INFO Started NetworkTrafficServerConnector@2ee67803{HTTP/1.1,[http/1.1]}{0.0.0.0:8081}

使用模式注册表

现在,模式注册表正在端口8081上运行。要与模式注册表交互,有一个 REST API。我们可以使用curl来访问它。第一步是在模式注册表中注册一个模式。为此,我们必须将我们的 JSON 模式嵌入到另一个 JSON 对象中,并且必须转义一些特殊字符并添加一个有效负载:

  • 在开始时,我们必须添加{ \"schema\": \"

  • 所有双引号(")都应该用反斜杠(\")转义

  • 最后,我们必须添加\" }

是的,正如你可以猜到的,API 有几个命令来查询模式注册表。

在一个值主题下注册模式的新版本

要使用curl命令注册位于src/main/resources/路径中,如列表 5.2中列出的healthcheck.avscAvro 模式,我们使用以下命令:

$ curl -X POST -H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{ "schema": "{ \"name\": \"HealthCheck\", \"namespace\": \"kioto.avro\", \"type\": \"record\", \"fields\": [ { \"name\": \"event\", \"type\": \"string\" }, { \"name\": \"factory\", \"type\": \"string\" }, { \"name\": \"serialNumber\", \"type\": \"string\" }, { \"name\": \"type\", \"type\": \"string\" }, { \"name\": \"status\", \"type\": \"string\"}, { \"name\": \"lastStartedAt\", \"type\": \"long\", \"logicalType\": \"timestamp-millis\"}, { \"name\": \"temperature\", \"type\": \"float\" }, { \"name\": \"ipAddress\", \"type\": \"string\" } ]} " }' \
http://localhost:8081/subjects/healthchecks-avro-value/versions

输出应该是这样的:

{"id":1}

这意味着我们已经将HealthChecks模式注册为版本"id":1(恭喜,这是您的第一个版本)。

注意,该命令在名为healthchecks-avro-value的主题上注册模式。Schema Registry 没有关于主题的信息(我们还没有创建healthchecks-avro主题)。序列化器/反序列化器遵循一个惯例,即在遵循<主题>-value 格式的名称下注册模式。在这种情况下,由于该模式用于消息值,我们使用后缀-value。如果我们想使用 Avro 来标识我们的消息键,我们将使用<主题>-key 格式。

例如,要获取我们模式的 ID,我们使用以下命令:

$ curl http://localhost:8081/subjects/healthchecks-avro-value/versions/

以下输出是模式 ID:

[1]

使用模式 ID,为了检查我们模式的值,我们使用以下命令:

$ curl http://localhost:8081/subjects/healthchecks-avro-value/versions/1

输出是这里显示的模式值:

{"subject":"healthchecks-avro-value","version":1,"id":1,"schema":"{\"type\":\"record\",\"name\":\"HealthCheck\",\"namespace\":\"kioto.avro\",\"fields\":[{\"name\":\"event\",\"type\":\"string\"},{\"name\":\"factory\",\"type\":\"string\"},{\"name\":\"serialNumber\",\"type\":\"string\"},{\"name\":\"type\",\"type\":\"string\"},{\"name\":\"status\",\"type\":\"string\"},{\"name\":\"lastStartedAt\",\"type\":\"long\",\"logicalType\":\"timestamp-millis\"},{\"name\":\"temperature\",\"type\":\"float\"},{\"name\":\"ipAddress\",\"type\":\"string\"}]}"}

在–key 主题下注册模式的新的版本

例如,要将我们模式的新的版本注册到healthchecks-avro-key主题下,我们将执行以下命令(不要运行它;这只是为了举例):

curl -X POST -H "Content-Type: application/vnd.schemaregistry.v1+json"\
--data 'our escaped avro data' \
http://localhost:8081/subjects/healthchecks-avro-key/versions

输出应该是这样的:

{"id":1}

将现有模式注册到新主题

假设有一个名为healthchecks-value1的主题上已注册的模式,我们需要这个模式在名为healthchecks-value2的主题上可用。

以下命令从healthchecks-value1读取现有模式并将其注册到healthchecks-value2(假设jq工具已经安装):

curl -X POST -H "Content-Type: application/vnd.schemaregistry.v1+json"\
--data "{\"schema\": $(curl -s http://localhost:8081/subjects/healthchecks-value1/versions/latest | jq '.schema')}" \
http://localhost:8081/subjects/healthchecks-value2/versions

输出应该是这样的:

{"id":1}

列出所有主题

要列出所有主题,你可以使用以下命令:

curl -X GET http://localhost:8081/subjects

输出应该是这样的:

["healthcheck-avro-value","healthchecks-avro-key"]

通过其全局唯一 ID 获取模式

获取模式,你可以使用以下命令:

curl -X GET http://localhost:8081/schemas/ids/1

输出应该是这样的:

{"schema":"{\"type\":\"record\",\"name\":\"HealthCheck\",\"namespace\":\"kioto.avro\",\"fields\":[{\"name\":\"event\",\"type\":\"string\"},{\"name\":\"factory\",\"type\":\"string\"},{\"name\":\"serialNumber\",\"type\":\"string\"},{\"name\":\"type\",\"type\":\"string\"},{\"name\":\"status\",\"type\":\"string\"},{\"name\":\"lastStartedAt\",\"type\":\"long\",\"logicalType\":\"timestamp-millis\"},{\"name\":\"temperature\",\"type\":\"float\"},{\"name\":\"ipAddress\",\"type\":\"string\"}]}"}

列出在healthchecks–value主题下注册的所有模式版本

要列出在healthchecks-value主题下注册的所有模式版本,你可以使用以下命令:

curl -X GET http://localhost:8081/subjects/healthchecks-value/versions

输出应该是这样的:

[1]

获取在healthchecks-value主题下注册的模式的版本 1

要获取在healthchecks-value主题下注册的模式的版本 1,你可以使用以下命令:

curl -X GET http://localhost:8081/subjects/ healthchecks-value/versions/1

输出应该是这样的:

{"subject":" healthchecks-value","version":1,"id":1}

删除在healthchecks-value主题下注册的模式的版本 1

要删除在healthchecks-value主题下注册的模式的版本 1,你可以使用以下命令:

curl -X DELETE http://localhost:8081/subjects/healthchecks-value/versions/1

输出应该是这样的:

1

删除在healthchecks-value主题下最近注册的模式

要删除在healthchecks-value主题下最近注册的模式,你可以使用以下命令:

curl -X DELETE http://localhost:8081/subjects/healthchecks-value/versions/latest

输出应该是这样的:

2

删除在healthchecks–value主题下注册的所有模式版本

要删除在healthchecks-value主题下注册的所有模式版本,你可以使用以下命令:

curl -X DELETE http://localhost:8081/subjects/healthchecks-value

输出应该是这样的:

[3]

检查是否在healthchecks-key主题下已注册该模式

要检查是否在healthchecks-key主题下已注册该模式,可以使用以下命令:

curl -X POST -H "Content-Type: application/vnd.schemaregistry.v1+json"\
--data 'our escaped avro data' \
http://localhost:8081/subjects/healthchecks-key

输出应该是这样的:

{"subject":"healthchecks-key","version":3,"id":1}

healthchecks-value主题下的最新模式进行模式兼容性测试

要对healthchecks-value主题下的最新模式进行模式兼容性测试,可以使用以下命令:

curl -X POST -H "Content-Type: application/vnd.schemaregistry.v1+json"\
--data 'our escaped avro data' \
http://localhost:8081/compatibility/subjects/healthchecks-value/versions/latest

输出应该是这样的:

{"is_compatible":true}

获取顶级兼容性配置

要获取顶级兼容性配置,可以使用以下命令:

curl -X GET http://localhost:8081/config

输出应该是这样的:

{"compatibilityLevel":"BACKWARD"}

全局更新兼容性要求

要全局更新兼容性要求,可以使用以下命令:

curl -X PUT -H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{"compatibility": "NONE"}' \
http://localhost:8081/config

输出应该是这样的:

{"compatibility":"NONE"}

更新healthchecks-value主题下的兼容性要求

要更新healthchecks-value主题下的兼容性要求,可以使用以下命令:

curl -X PUT -H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{"compatibility": "BACKWARD"}' \
http://localhost:8081/config/healthchecks-value

输出应该是这样的:

{"compatibility":"BACKWARD"}

Java AvroProducer

现在,我们应该修改我们的 Java 生产者以发送 Avro 格式的消息。首先,重要的是要提到,在 Avro 中有两种类型的消息:

  • 特定记录:包含 Avro 模式(avsc)的文件被发送到特定的 Avro 命令以生成相应的 Java 类。

  • 通用记录:在此方法中,使用类似于映射字典的数据结构。这意味着您可以通过名称设置和获取字段,并且您必须知道它们对应的类型。此选项不是类型安全的,但它比其他选项提供了更多的灵活性,并且在这里,版本随着时间的推移更容易管理。在这个例子中,我们将使用这种方法。

在我们开始编写代码之前,请记住,在上一章中,我们向 Kafka 客户端添加了支持 Avro 的库。如果您还记得,build.gradle文件有一个特殊的仓库,其中包含所有这些库。

Confluent 的仓库在以下行指定:

repositories {
 ...
 maven { url 'https://packages.confluent.io/maven/' }
 }

在依赖关系部分,我们应该添加特定的 Avro 库:

dependencies {
 ...
 compile 'io.confluent:kafka-avro-serializer:5.0.0'
 }

不要使用 Apache Avro 提供的库,因为它们将不起作用。

如我们所知,要构建 Kafka 消息生产者,我们使用 Java 客户端库;特别是生产者 API。如我们所知,所有 Kafka 生产者都应该有两个必备条件:成为一个KafkaProducer并设置特定的Properties,例如列表 5.3

import io.confluent.kafka.serializers.KafkaAvroSerializer; 
import org.apache.avro.Schema;
import org.apache.avro.Schema.Parser;
import org.apache.avro.generic.GenericRecord;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.common.serialization.StringSerializer;

public final class AvroProducer {
  private final Producer<String, GenericRecord> producer; //1
  private Schema schema;

  public AvroProducer(String brokers, String schemaRegistryUrl) { //2
    Properties props = new Properties();
    props.put("bootstrap.servers", brokers);
    props.put("key.serializer", StringSerializer.class); //3
    props.put("value.serializer", KafkaAvroSerializer.class); //4
    props.put("schema.registry.url", schemaRegistryUrl) //5
    producer = new KafkaProducer<>(props);

    try {
      schema = (new Parser()).parse( new 
      File("src/main/resources/healthcheck.avsc")); //6
    } catch (IOException e) {
      // deal with the Exception
    }
  }
  ...
}

列表 5.3:AvroProducer 构造函数

分析AvroProducer构造函数显示以下内容:

  • 在行//1中,现在的值是org.apache.avro.generic.GenericRecord类型

  • 在行//2中,构造函数现在接收模式注册表 URL

  • 在行//3中,消息键的序列化器类型仍然是StringSerializer

  • 在行//4中,消息值的序列化器类型现在是KafkaAvroSerializer

  • 在行//5中,将 Schema Registry URL 添加到生产者属性中

  • 在行//6中,使用 Schema Parser 解析包含模式定义的 avsc 文件

由于我们选择了使用通用记录,我们必须加载模式。请注意,我们本可以从 Schema Registry 获取模式,但这并不安全,因为我们不知道哪个版本的模式已注册。相反,将模式与代码一起存储是一种聪明且安全的做法。这样,即使有人更改 Schema Registry 中注册的模式,我们的代码也会始终产生正确的数据类型。

现在,在src/main/java/kioto/avro目录下,创建一个名为AvroProducer.java的文件,内容为列表 5.4

package kioto.avro;
import ...
public final class AvroProducer {
 /* here the Constructor code in Listing 5.3 */

 public final class AvroProducer {

  private final Producer<String, GenericRecord> producer;
  private Schema schema;

  public AvroProducer(String brokers, String schemaRegistryUrl) {
    Properties props = new Properties();
    props.put("bootstrap.servers", brokers);
    props.put("key.serializer", StringSerializer.class);
    props.put("value.serializer", KafkaAvroSerializer.class);
    props.put("schema.registry.url", schemaRegistryUrl);
    producer = new KafkaProducer<>(props);
    try {
      schema = (new Parser()).parse(new                   
      File("src/main/resources/healthcheck.avsc"));
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

  public final void produce(int ratePerSecond) {
    long waitTimeBetweenIterationsMs = 1000L / (long)ratePerSecond;
    Faker faker = new Faker();

    while(true) {
      HealthCheck fakeHealthCheck =
          new HealthCheck(
              "HEALTH_CHECK",
              faker.address().city(),
              faker.bothify("??##-??##", true),
              Constants.machineType.values()                                                                                                                 
              [faker.number().numberBetween(0,4)].toString(),
              Constants.machineStatus.values()                                        
              [faker.number().numberBetween(0,3)].toString(),
              faker.date().past(100, TimeUnit.DAYS),
              faker.number().numberBetween(100L, 0L),
              faker.internet().ipV4Address());
              GenericRecordBuilder recordBuilder = new                                       
              GenericRecordBuilder(schema);
              recordBuilder.set("event", fakeHealthCheck.getEvent());
              recordBuilder.set("factory", 
              fakeHealthCheck.getFactory());
              recordBuilder.set("serialNumber",                                          
              fakeHealthCheck.getSerialNumber());
              recordBuilder.set("type", fakeHealthCheck.getType());
              recordBuilder.set("status", fakeHealthCheck.getStatus());
              recordBuilder.set("lastStartedAt",                                      
              fakeHealthCheck.getLastStartedAt().getTime());
              recordBuilder.set("temperature",                                          
              fakeHealthCheck.getTemperature());
              recordBuilder.set("ipAddress",   
              fakeHealthCheck.getIpAddress());
              Record avroHealthCheck = recordBuilder.build();
              Future futureResult = producer.send(new ProducerRecord<>               
              (Constants.getHealthChecksAvroTopic(), avroHealthCheck));
      try {
        Thread.sleep(waitTimeBetweenIterationsMs);
        futureResult.get();
      } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
      }
    }
  }

  public static void main( String[] args) {
    new AvroProducer("localhost:9092",                                       
    "http://localhost:8081").produce(2);
  }
}

列表 5.4:AvroProducer.java

AvroProducer类的分析显示以下内容:

  • 在行//1中,ratePerSecond是 1 秒内要发送的消息数量

  • 在行//2中,为了模拟重复,我们使用了一个无限循环(在生产环境中尽量避免这样做)

  • 在行//3中,现在我们可以使用GenericRecordBuilder创建GenericRecord对象

  • 在行//4中,我们使用 Java Future 将记录发送到healthchecks-avro主题

  • 在行//5中,我们这次等待再次发送消息

  • 在行//6中,我们读取 Future 的结果

  • 在行//7中,一切都在本地的 9092 端口上的代理上运行,Schema Registry 也在本地的 8081 端口上运行,以 1 秒的间隔发送两条消息

运行 AvroProducer

要构建项目,请在kioto目录下运行以下命令:

$ gradle jar

如果一切正常,输出将类似于这里所示:

BUILD SUCCESSFUL in 3s
 1 actionable task: 1 executed
  1. 如果它还没有运行,请转到 Confluent 目录并启动它:
$ ./bin/confluent start
  1. 代理正在 9092 端口上运行。要创建healthchecks-avro主题,执行以下命令:
$ ./bin/kafka-topics --zookeeper localhost:2181 --create --topic healthchecks-avro --replication-factor 1 --partitions 4
  1. 注意,我们只是创建了一个普通主题,没有指示消息的格式。

  2. healthchecks-avro主题运行控制台消费者:

$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 --topic healthchecks-avro
  1. 从我们的 IDE 中运行AvroProducer的 main 方法。

  2. 控制台消费者的输出应该类似于这里所示:

HEALTH_CHECKLake JeromyGE50-GF78HYDROELECTRICRUNNING�����Y,B227.30.250.185
HEALTH_CHECKLockmanlandMW69-LS32GEOTHERMALRUNNING֗���YB72.194.121.48
HEALTH_CHECKEast IsidrofortIH27-WB64NUCLEARSHUTTING_DOWN�̤��YB88.136.134.241
HEALTH_CHECKSipesshireDH05-YR95HYDROELECTRICRUNNING����Y�B254.125.63.235
HEALTH_CHECKPort EmeliaportDJ83-UO93GEOTHERMALRUNNING���Y�A190.160.48.125

二进制格式对人类来说难以阅读,不是吗?我们只能读取字符串,但不能读取记录的其余部分。

为了解决我们的可读性问题,我们应该使用kafka-avro-console-consumer。这个花哨的消费者将 Avro 记录反序列化并打印成人类可读的 JSON 对象。

从命令行运行一个healthchecks-avro主题的 Avro 控制台消费者:

$ ./bin/kafka-avro-console-consumer --bootstrap-server localhost:9092 --topic healthchecks-avro

控制台消费者的输出应该类似于以下内容:

{"event":"HEALTH_CHECK","factory":"Lake Jeromy","serialNumber":" GE50-GF78","type":"HYDROELECTRIC","status":"RUNNING","lastStartedAt":1537320719954,"temperature":35.0,"ipAddress":"227.30.250.185"}
{"event":"HEALTH_CHECK","factory":"Lockmanland","serialNumber":" MW69-LS32","type":"GEOTHERMAL","status":"RUNNING","lastStartedAt":1534188452893,"temperature":61.0,"ipAddress":"72.194.121.48"}
{"event":"HEALTH_CHECK","factory":"East Isidrofort","serialNumber":" IH27-WB64","type":"NUCLEAR","status":"SHUTTING_DOWN","lastStartedAt":1539296403179,"temperature":62.0,"ipAddress":"88.136.134.241"}
...

现在,我们终于开始以 Avro 格式生产 Kafka 消息。在 Schema Registry 和 Confluent 库的帮助下,这个任务相当简单。正如描述的那样,在生产环境中经过许多挫折后,通用记录模式比特定记录模式更好,因为知道我们正在使用哪个模式来生产数据更好。将模式代码与代码一起保存可以给你这个保证。

如果我们在生产数据之前从 Schema Registry 获取模式会发生什么?正确的答案是这取决于,这取决于

auto.register.schemas属性。如果此属性设置为 true,当您请求 Schema Registry 中不存在的模式时,它将自动注册为新的模式(在生产环境中不建议使用此选项,因为它容易出错)。如果属性设置为 false,则不会存储模式,并且由于模式不匹配,我们将得到一个漂亮的异常(不要相信我,读者;去获取这个证明)

Java AvroConsumer

让我们创建一个 Kafka AvroConsumer,我们将使用它来接收输入记录。正如我们已经知道的,所有的 Kafka Consumers 都应该有两个前提条件:成为一个KafkaConsumer并设置特定的属性,如列表 5.5所示:

import io.confluent.kafka.serializers.KafkaAvroDeserializer;
import org.apache.avro.generic.GenericRecord;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.common.serialization.StringSerializer;

public final class AvroConsumer {
  private Consumer<String, GenericRecord> consumer; //1
  public AvroConsumer(String brokers, String schemaRegistryUrl) { //2
     Properties props = new Properties();
     props.put("group.id", "healthcheck-processor");
     props.put("bootstrap.servers", brokers);
     props.put("key.deserializer", StringDeserializer.class); //3
     props.put("value.deserializer", KafkaAvroDeserializer.class); //4
     props.put("schema.registry.url", schemaRegistryUrl); //5
     consumer = new KafkaConsumer<>(props); //6
  }
 ...
}

列表 5.5:AvroConsumer 构造函数

分析AvroConsumer构造函数的变化,我们可以看到以下内容:

  • 在行//1中,值现在是org.apache.avro.generic.GenericRecord类型

  • 在行//2中,构造函数现在接收 Schema Registry URL

  • 在行//3中,消息键的反序列化类型仍然是StringDeserializer

  • 在行//4中,值的反序列化类型现在是KafkaAvroDeserializer

  • 在行//5中,将 Schema Registry URL 添加到消费者属性中

  • 在行//6中,使用这些Properties,我们构建了一个具有字符串键和GenericRecord值的KafkaConsumer<String, GenericRecord>

重要的是要注意,当为反序列化器定义 Schema Registry URL 以获取模式时,消息只包含模式 ID 而不是模式本身。

Java AvroProcessor

现在,在src/main/java/kioto/avro目录下,创建一个名为AvroProcessor.java的文件,其内容为列表 5.6

package kioto.plain;
import ...
public final class AvroProcessor {
  private Consumer<String, GenericRecord> consumer;
  private Producer<String, String> producer;

  public AvroProcessor(String brokers , String schemaRegistryUrl) {
    Properties consumerProps = new Properties();
    consumerProps.put("bootstrap.servers", brokers);
    consumerProps.put("group.id", "healthcheck-processor");
    consumerProps.put("key.deserializer", StringDeserializer.class);
    consumerProps.put("value.deserializer", KafkaAvroDeserializer.class);
    consumerProps.put("schema.registry.url", schemaRegistryUrl);
    consumer = new KafkaConsumer<>(consumerProps);

    Properties producerProps = new Properties();
    producerProps.put("bootstrap.servers", brokers);
    producerProps.put("key.serializer", StringSerializer.class);
    producerProps.put("value.serializer", StringSerializer.class);
    producer = new KafkaProducer<>(producerProps);
 }

列表 5.6:AvroProcessor.java(第一部分)

分析AvroProcessor类的前一部分,我们可以看到以下内容:

  • 在第一部分,我们声明了一个AvroConsumer,如列表 5.5所示

  • 在第二部分,我们声明了一个AvroProducer,如列表 5.4所示

现在,在src/main/java/kioto/avro目录下,让我们用列表 5.7的内容来完成AvroProcessor.java文件:

public final void process() {
  consumer.subscribe(Collections.singletonList(
    Constants.getHealthChecksAvroTopic())); //1
    while(true) {
      ConsumerRecords records = consumer.poll(Duration.ofSeconds(1L));
      for(Object record : records) {
        ConsumerRecord it = (ConsumerRecord) record;
        GenericRecord healthCheckAvro = (GenericRecord) it.value(); //2
        HealthCheck healthCheck = new HealthCheck ( //3
          healthCheckAvro.get("event").toString(),
          healthCheckAvro.get("factory").toString(),
          healthCheckAvro.get("serialNumber").toString(),
          healthCheckAvro.get("type").toString(),
          healthCheckAvro.get("status").toString(),
          new Date((Long)healthCheckAvro.get("lastStartedAt")),
          Float.parseFloat(healthCheckAvro.get("temperature").toString()),
          healthCheckAvro.get("ipAddress").toString());
          LocalDate startDateLocal= 
          healthCheck.getLastStartedAt().toInstant()
                      .atZone(ZoneId.systemDefault()).toLocalDate(); //4
          int uptime = Period.between(startDateLocal,     
          LocalDate.now()).getDays(); //5
          Future future =
               producer.send(new ProducerRecord<>(
                             Constants.getUptimesTopic(),
                             healthCheck.getSerialNumber(),
                             String.valueOf(uptime))); //6
          try {
            future.get();
          } catch (InterruptedException | ExecutionException e) {
            // deal with the exception
          }
        }
      }
    }

    public static void main(String[] args) {
       new      
  AvroProcessor("localhost:9092","http://localhost:8081").process();//7
    }
}

列表 5.7:AvroProcessor.java(第二部分)

分析AvroProcessor,我们可以看到以下内容:

  • 在行//1中,消费者订阅了新的 Avro 主题。

  • 在行//2中,我们正在消费类型为GenericRecord的消息。

  • 在行//3中,将 Avro 记录反序列化以提取HealthCheck对象。

  • 在行//4中,将开始时间转换为当前时区的格式。

  • 在行//5中,计算了运行时间。

  • 在行//6中,将运行时间写入uptimes主题,使用序列号作为键,运行时间作为值。两个值都作为普通字符串写入。

  • 在行//7中,所有操作都在本地的9092端口上的代理上运行,并且模式注册表在本地的8081端口上运行。

如前所述,代码不是类型安全的;所有类型都在运行时进行检查。因此,请对此格外小心。例如,字符串不是java.lang.String;它们是org.apache.avro.util.Utf8类型。请注意,我们通过直接在对象上调用toString()方法来避免类型转换。其余的代码保持不变。

运行 AvroProcessor

要构建项目,请从kioto目录中运行以下命令:

$ gradle jar

如果一切正常,输出将类似于以下内容:

BUILD SUCCESSFUL in 3s
 1 actionable task: 1 executed

运行uptimes主题的控制台消费者,如下所示:

$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 --topic uptimes --property print.key=true
  1. 从 IDE 中运行AvroProcessor的主方法

  2. 从 IDE 中运行AvroProducer的主方法

  3. 控制台消费者对于uptimes主题的输出应类似于以下内容:

EW05-HV36 33
BO58-SB28 20
DV03-ZT93 46
...

摘要

在本章中,我们展示了,而不是以 JSON 格式发送数据,如何使用 AVRO 作为序列化格式。AVRO(例如,相对于 JSON)的主要好处是数据必须符合模式。AVRO 相对于 JSON 的另一个优点是,以二进制格式发送时,消息更加紧凑,尽管 JSON 是可读的。

模式存储在模式注册表中,这样所有用户都可以咨询模式版本历史,即使那些消息的生产者和消费者的代码不再可用。

Apache Avro 还保证了此格式中所有消息的向前和向后兼容性。通过遵循一些基本规则实现向前兼容性,例如,当添加新字段时,将其值声明为可选的。

Apache Kafka 鼓励在 Kafka 系统中使用 Apache Avro 和模式注册表来存储所有数据和模式,而不是仅使用纯文本或 JSON。使用这个成功的组合,你可以保证你的系统可以进化。

第六章:Kafka Streams

在本章中,我们将不再像前几章那样使用 Kafka Java API 来处理生产者和消费者,而是将使用 Kafka Streams,这是 Kafka 用于流处理的模块。

本章涵盖了以下主题:

  • Kafka Streams 简而言之

  • Kafka Streams 项目设置

  • 编码和运行 Java PlainStreamsProcessor

  • 使用 Kafka Streams 进行扩展

  • 编码和运行 Java CustomStreamsProcessor

  • 编码和运行 Java AvroStreamsProcessor

  • 编码和运行 Late EventProducer

  • 编码和运行 Kafka Streams 处理器

Kafka Streams 简而言之

Kafka Streams 是一个库,也是 Apache Kafka 的一部分,用于处理进入和离开 Kafka 的流。在函数式编程中,集合上有几个操作,如下所示:

  • filter

  • map

  • flatMap

  • groupBy

  • join

Apache Spark、Apache Flink、Apache Storm 和 Akka Streams 等流平台的成功在于将这些无状态函数纳入数据处理。Kafka Streams 提供了一个 DSL 来将这些函数纳入数据流操作。

Kafka Streams 还具有有状态的转换;这些是与聚合相关的操作,依赖于消息作为组的状态,例如,窗口函数和对迟到数据的支持。Kafka Streams 是一个库,这意味着 Kafka Streams 应用程序可以通过执行您的应用程序 jar 来部署。无需在服务器上部署应用程序,这意味着您可以使用任何应用程序来运行 Kafka Streams 应用程序:Docker、Kubernetes、本地服务器等。Kafka Streams 的美妙之处在于它允许水平扩展。也就是说,如果在同一 JVM 中运行,它将执行多个线程,但如果启动了多个应用程序实例,它可以在多个 JVM 中运行以进行扩展。

Apache Kafka 核心是用 Scala 编写的;然而,Kafka Streams 和 KSQL 是用 Java 8 编写的。Kafka Streams 包含在 Apache Kafka 的开源发行版中。

项目设置

第一步是修改 kioto 项目。我们必须在 build.gradle 中添加依赖项,如 Listing 6.1 所示:

apply plugin: 'java'
apply plugin: 'application'

sourceCompatibility = '1.8'

mainClassName = 'kioto.ProcessingEngine'

repositories {
 mavenCentral()
 maven { url 'https://packages.confluent.io/maven/' }
}

version = '0.1.0'

dependencies {
  compile 'com.github.javafaker:javafaker:0.15'
  compile 'com.fasterxml.jackson.core:jackson-core:2.9.7'
  compile 'io.confluent:kafka-avro-serializer:5.0.0'
  compile 'org.apache.kafka:kafka_2.12:2.0.0'
  compile 'org.apache.kafka:kafka-streams:2.0.0'
  compile 'io.confluent:kafka-streams-avro-serde:5.0.0'
}

jar {
  manifest {
    attributes 'Main-Class': mainClassName
  } from {
    configurations.compile.collect {
       it.isDirectory() ? it : zipTree(it)
    }
  }
  exclude "META-INF/*.SF"
  exclude "META-INF/*.DSA"
  exclude "META-INF/*.RSA"
}

列表 6.1:Kioto Gradle 构建文件用于 Kafka Streams

对于本章的示例,我们还需要 Jackson 的依赖项。要使用 Kafka Streams,我们只需要一个依赖项,如下面的代码片段所示:

compile 'org.apache.kafka:kafka-streams:2.0.0'

要在 Kafka Streams 中使用 Apache Avro,我们需要添加以下代码中给出的序列化和反序列化器:

compile 'io.confluent:kafka-streams-avro-serde:5.0.0'

以下行是运行 Kafka Streams 应用程序作为 jar 所必需的。构建生成了一个胖 jar:

configurations.compile.collect {
  it.isDirectory() ? it : zipTree(it)
}

项目的目录树结构应该是这样的:

src
main
--java
----kioto
------avro
------custom
------events
------plain
------serde
--resources
test

Java PlainStreamsProcessor

现在,在 src/main/java/kioto/plain 目录下,创建一个名为 PlainStreamsProcessor.java 的文件,其内容如 Listing 6.2 所示:

import ...
public final class PlainStreamsProcessor {
  private final String brokers;
  public PlainStreamsProcessor(String brokers) {
    super();
    this.brokers = brokers;
  }
  public final void process() {
    // below we will see the contents of this method 
  }
  public static void main(String[] args) {
    (new PlainStreamsProcessor("localhost:9092")).process();
  }
}

列表 6.2:PlainStreamsProcessor.java

所有的魔法都在 process() 方法内部发生。在 Kafka Streams 应用程序中,第一步是获取一个 StreamsBuilder 实例,如下面的代码所示:

StreamsBuilder streamsBuilder = new StreamsBuilder();

StreamsBuilder 是一个允许构建拓扑的对象。在 Kafka Streams 中,拓扑是数据管道的结构描述。拓扑是涉及流之间转换的一系列步骤。拓扑在流中是一个非常重要的概念;它也被用于其他技术,如 Apache Storm。

StreamsBuilder 用于从主题中消费数据。在 Kafka Streams 的上下文中,还有两个重要的概念:KStream,它是记录流的表示,以及 KTable,它是流中变化的日志(我们将在第七章 KSQL 中详细看到 KTables)。要从主题获取 KStream,我们使用 StreamsBuilderstream() 方法,如下所示:

KStream healthCheckJsonStream = 
  streamsBuilder.stream( Constants.getHealthChecksTopic(), 
    Consumed.with(Serdes.String(), Serdes.String()));

有一个 stream() 方法的实现,它只接收主题名称作为参数。但是,使用可以指定序列化器的实现是一个好的实践,就像在这个例子中,我们必须指定 Consumed 类的键和值的序列化器;在这种情况下,两者都是字符串。

不要让序列化器通过应用程序范围的属性来指定,因为同一个 Kafka Streams 应用程序可能需要从具有不同数据格式的多个数据源中读取。

我们已经获取了一个 JSON 流。拓扑中的下一步是获取 HealthCheck 对象流,我们通过构建以下流来实现:

KStream healthCheckStream = healthCheckJsonStream.mapValues((v -> {
  try {
    return Constants.getJsonMapper().readValue(
      (String) v, HealthCheck.class);
  } catch (IOException e) {
    // deal with the Exception
  }
 }));

首先,请注意我们正在使用 mapValues() 方法,所以就像在 Java 8 中,该方法接收一个 lambda 表达式。mapValues() 方法还有其他实现,但在这里我们使用只有一个参数的 lambda (v->)。

这里的 mapValues() 可以这样理解:对于输入流中的每个元素,我们都在将 JSON 对象转换到 HealthCheck 对象,这种转换可能会抛出 IOException,因此我们捕获了它。

回顾到目前,在第一次转换中,我们从主题读取了一个带有 (String, String) 对的流。在第二次转换中,我们从 JSON 中的值转换到 HealthCheck 对象中的值。

在第三步,我们将计算 uptime 并将其发送到 uptimeStream,如下面的代码块所示:

KStream uptimeStream = healthCheckStream.map(((KeyValueMapper)(k, v)-> {
  HealthCheck healthCheck = (HealthCheck) v;
  LocalDate startDateLocal = healthCheck.getLastStartedAt().toInstant()
              .atZone(ZoneId.systemDefault()).toLocalDate();
  int uptime = Period.between(startDateLocal, LocalDate.now()).getDays();
  return new KeyValue<>(
    healthCheck.getSerialNumber(), String.valueOf(uptime));
 }));

注意我们正在使用 map() 方法,同样地,就像在 Java 8 中,该方法接收一个 lambda 表达式。map() 方法还有其他实现;在这里,我们使用有两个参数的 lambda ((k, v)->)。

这里的map()可以读作如下:对于输入流中的每个元素,我们提取键值对(键,值)。我们只使用值(无论如何,键是null),将其转换为HealthCheck,提取两个属性(开始时间和SerialNumber),计算uptime,并返回一个新的键值对(SerialNumberuptime)。

最后一步是将这些值写入uptimes主题,如下所示:

uptimeStream.to( Constants.getUptimesTopic(), 
  Produced.with(Serdes.String(), Serdes.String()));

再次强调,直到我累为止:强烈建议声明我们 Stream 的数据类型。例如,在这种情况下,始终声明键值对是类型(String, String)。

这里是步骤的总结:

  1. 从输入主题读取类型为(String, String)的键值对

  2. 将每个 JSON 对象反序列化为HealthCheck

  3. 计算uptimes

  4. uptimes以键值对形式(String, String)写入输出主题

最后,是时候启动 Kafka Streams 引擎了。

在启动之前,我们需要指定拓扑和两个属性,即代理和应用程序 ID,如下所示:

Topology topology = streamsBuilder.build();
Properties props = new Properties();
props.put("bootstrap.servers", this.brokers);
props.put("application.id", "kioto");
KafkaStreams streams = new KafkaStreams(topology, props);
streams.start();

注意,序列化和反序列化器只是在从和向主题读写时明确定义的。因此,我们与应用程序范围内的单一数据类型无关,我们可以使用不同的数据类型从和向主题读写,这在实践中是持续发生的。

同时,遵循这个良好的实践,在不同主题之间,关于使用哪个 Serde 没有歧义。

运行 PlainStreamsProcessor

要构建项目,从kioto目录运行以下命令:

$ gradle build

如果一切正常,输出将类似于以下内容:

BUILD SUCCESSFUL in 1s
6 actionable task: 6 up-to-date
  1. 第一步是运行uptimes主题的控制台消费者,如下面的代码片段所示:
$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 
--topic uptimes --property print.key=true
  1. 从 IDE 中运行PlainStreamsProcessor的主方法

  2. 从 IDE 中运行PlainProducer(在之前的章节中构建)的主方法

  3. uptimes主题的控制台消费者的输出应该类似于以下内容:

EW05-HV36 33
BO58-SB28 20
DV03-ZT93 46
...

使用 Kafka Streams 进行扩展

为了按照承诺扩展架构,我们必须遵循以下步骤:

  1. 运行uptimes主题的控制台消费者,如下所示:
$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 
--topic uptimes --property print.key=true
  1. 从命令行运行应用程序 jar,如下面的代码所示:
$ java -cp ./build/libs/kioto-0.1.0.jar 
kioto.plain.PlainStreamsProcessor

这时我们验证我们的应用程序是否真的可以扩展。

  1. 从一个新的命令行窗口,我们执行相同的命令,如下所示:
$ java -cp ./build/libs/kioto-0.1.0.jar 
kioto.plain.PlainStreamsProcessor

输出应该类似于以下内容:

2017/07/05 15:03:18.045 INFO ... Setting newly assigned 
partitions [healthchecks-2, healthchecks -3]

如果我们还记得第一章中关于配置 Kafka 的理论,当我们创建主题时,我们指定它有四个分区。这个来自 Kafka Streams 的好消息告诉我们,应用程序被分配到我们的主题的两个和三个分区。

看看下面的日志:

...
2017/07/05 15:03:18.045 INFO ... Revoking previously assigned partitions [healthchecks -0, healthchecks -1, healthchecks -2, healthchecks -3]
2017/07/05 15:03:18.044 INFO ... State transition from RUNNING to PARTITIONS_REVOKED
2017/07/05 15:03:18.044 INFO ... State transition from RUNNING to REBALANCING
2017/07/05 15:03:18.044 INFO ... Setting newly assigned partitions [healthchecks-2, healthchecks -3]
...

我们可以读到第一个实例使用了四个分区,然后当我们运行第二个实例时,它进入了一个状态,其中分区被重新分配给消费者;第一个实例被分配了两个分区:healthchecks-0healthchecks-1

这就是 Kafka Streams 如何平滑扩展的方式。记住,这一切之所以能够工作,是因为消费者是同一个消费者组的成员,并且通过application.id属性由 Kafka Streams 进行控制。

我们还必须记住,可以通过设置num.stream.threads属性来修改分配给应用程序每个实例的线程数。这样,每个线程都将独立,拥有自己的生产者和消费者。这确保了我们的服务器资源被更有效地使用。

Java CustomStreamsProcessor

总结到目前为止发生的事情,在之前的章节中,我们看到了如何在 Kafka 中创建生产者、消费者和简单的处理器。我们还看到了如何使用自定义 SerDe、使用 Avro 和 Schema Registry 来完成相同的事情。到目前为止,在本章中,我们看到了如何使用 Kafka Streams 创建一个简单的处理器。

在本节中,我们将利用到目前为止的所有知识,使用 Kafka Streams 构建一个CustomStreamsProcessor来使用我们自己的 SerDe。

现在,在src/main/java/kioto/custom目录中,创建一个名为CustomStreamsProcessor.java的文件,其内容如列表 6.3所示,如下所示:

import ...
public final class CustomStreamsProcessor {
  private final String brokers;
  public CustomStreamsProcessor(String brokers) {
    super();
    this.brokers = brokers;
  }
  public final void process() {
    // below we will see the contents of this method
  }
  public static void main(String[] args) {
    (new CustomStreamsProcessor("localhost:9092")).process();
  }
}

列表 6.3: CustomStreamsProcessor.java

所有的魔法都在process()方法内部发生。

Kafka Streams 应用程序的第一步是获取一个StreamsBuilder实例,如下所示:

StreamsBuilder streamsBuilder = new StreamsBuilder();

我们可以重用之前章节中构建的Serdes。以下代码创建了一个将消息值反序列化为HealthCheck对象的KStream

Serde customSerde = Serdes.serdeFrom(
  new HealthCheckSerializer(), new HealthCheckDeserializer());

Serde类的serdeFrom()方法动态地将我们的HealthCheckSerializerHealthCheckDeserializer包装成一个单一的HealthCheck Serde

我们可以重用之前章节中构建的Serdes。以下代码创建了一个将消息值反序列化为HealthCheck对象的KStream

StreamsBuilder用于从主题中消费数据。与之前的章节相同,要从主题中获取KStream,我们使用StreamsBuilderstream()方法,如下所示:

KStream healthCheckStream =
  streamsBuilder.stream( Constants.getHealthChecksTopic(),
    Consumed.with(Serdes.String(), customSerde));

我们使用了一个实现,其中我们也可以指定序列化器,就像在这个例子中,我们必须指定Consumed类的序列化器,在这种情况下,键是一个字符串(总是null),而值的序列化器是我们的新customSerde

这里的魔法在于process()方法的其余代码与上一节相同;它也如下所示:

KStream uptimeStream = healthCheckStream.map(((KeyValueMapper)(k, v)-> {
  HealthCheck healthCheck = (HealthCheck) v;
  LocalDate startDateLocal = healthCheck.getLastStartedAt().toInstant()
               .atZone(ZoneId.systemDefault()).toLocalDate();
  int uptime =
      Period.between(startDateLocal, LocalDate.now()).getDays();
  return new KeyValue<>(
      healthCheck.getSerialNumber(), String.valueOf(uptime));
}));
uptimeStream.to( Constants.getUptimesTopic(),
      Produced.with(Serdes.String(), Serdes.String()));
Topology topology = streamsBuilder.build();
Properties props = new Properties();
props.put("bootstrap.servers", this.brokers);
props.put("application.id", "kioto");
KafkaStreams streams = new KafkaStreams(topology, props);
streams.start(); 

运行 CustomStreamsProcessor

要构建项目,请在kioto目录中运行以下命令:

$ gradle build

如果一切正确,输出将类似于以下内容:

BUILD SUCCESSFUL in 1s
6 actionable task: 6 up-to-date
  1. 第一步是运行 uptimes 主题的控制台消费者,如下所示:
$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 
--topic uptimes --property print.key=true
  1. 从我们的 IDE 中运行 CustomStreamsProcessor 的主方法

  2. 从我们的 IDE 中运行 CustomProducer 的主方法(在前面章节中构建)

  3. uptimes 主题的控制台消费者的输出应类似于以下内容:

 EW05-HV36 33
 BO58-SB28 20
 DV03-ZT93 46
 ...

Java AvroStreamsProcessor

在本节中,我们将看到如何使用汇集的所有这些功能:Apache Avro、Schema Registry 和 Kafka Streams。

现在,我们将使用与前面章节相同的 Avro 格式在我们的消息中。我们通过配置 Schema Registry URL 并使用 Kafka Avro 反序列化器来消费这些数据。对于 Kafka Streams,我们需要使用一个 Serde,因此我们在 Gradle 构建文件中添加了依赖项,如下所示:

compile 'io.confluent:kafka-streams-avro-serde:5.0.0'

这个依赖关系包含了前面章节中解释的 GenericAvroSerde 和特定的 avroSerde。这两个 Serde 实现允许我们使用 Avro 记录。

现在,在 src/main/java/kioto/avro 目录下,创建一个名为 AvroStreamsProcessor.java 的文件,其中包含 列表 6.4 的内容,如下所示:

import ...
public final class AvroStreamsProcessor {
  private final String brokers;
  private final String schemaRegistryUrl;
  public AvroStreamsProcessor(String brokers, String schemaRegistryUrl) {
    super();
    this.brokers = brokers;
    this.schemaRegistryUrl = schemaRegistryUrl;
  }
  public final void process() {
    // below we will see the contents of this method
  }
  public static void main(String[] args) {
    (new AvroStreamsProcessor("localhost:9092", 
        "http://localhost:8081")).process();
  }
}

列表 6.4:AvroStreamsProcessor.java

与前面的代码列表相比,一个主要的不同点是 Schema Registry URL 的指定。与之前一样,魔法发生在 process() 方法内部。

Kafka Streams 应用程序的第一步是获取一个 StreamsBuilder 实例,如下所示:

StreamsBuilder streamsBuilder = new StreamsBuilder();

第二步是获取 GenericAvroSerde 对象的实例,如下所示:

GenericAvroSerde avroSerde = new GenericAvroSerde();

由于我们使用的是 GenericAvroSerde,我们需要使用 Schema Registry URL(如前所述)对其进行配置;如下代码所示:

avroSerde.configure(
  Collections.singletonMap("schema.registry.url", schemaRegistryUrl), false);

GenericAvroSerdeconfigure() 方法接收一个映射作为参数;因为我们只需要一个包含单个条目的映射,所以我们使用了单例映射方法。

现在,我们可以使用这个 Serde 创建一个 KStream。以下代码生成一个包含 GenericRecord 对象的 Avro 流:

KStream avroStream =
  streamsBuilder.stream( Constants.getHealthChecksAvroTopic(),
    Consumed.with(Serdes.String(), avroSerde));

注意我们请求的 AvroTopic 的名称,以及我们必须指定 Consumed 类的键和值的序列化器;在这种情况下,键是一个 String(始终为 null),值的序列化器是我们的新 avroSerde

要反序列化 HealthCheck 流的值,我们在 mapValues() 方法的 lambda 表达式中应用了前面章节中使用的相同方法,其中一个参数(v->),如下所示:

KStream healthCheckStream = avroStream.mapValues((v -> {
  GenericRecord healthCheckAvro = (GenericRecord) v;
  HealthCheck healthCheck = new HealthCheck(
    healthCheckAvro.get("event").toString(),
    healthCheckAvro.get("factory").toString(),
    healthCheckAvro.get("serialNumber").toString(),
    healthCheckAvro.get("type").toString(),
    healthCheckAvro.get("status").toString(),
    new Date((Long) healthCheckAvro.get("lastStartedAt")),
    Float.parseFloat(healthCheckAvro.get("temperature").toString()),
    healthCheckAvro.get("ipAddress").toString());
  return healthCheck;
}));

再次,process() 方法的其余代码与前面章节相同,如下所示:

KStream uptimeStream = healthCheckStream.map(((KeyValueMapper)(k, v)-> {
  HealthCheck healthCheck = (HealthCheck) v;
  LocalDate startDateLocal = healthCheck.getLastStartedAt().toInstant()
               .atZone(ZoneId.systemDefault()).toLocalDate();
  int uptime =
     Period.between(startDateLocal, LocalDate.now()).getDays();
  return new KeyValue<>(
     healthCheck.getSerialNumber(), String.valueOf(uptime));
}));

uptimeStream.to( Constants.getUptimesTopic(),
      Produced.with(Serdes.String(), Serdes.String()));

Topology topology = streamsBuilder.build();
Properties props = new Properties();
props.put("bootstrap.servers", this.brokers);
props.put("application.id", "kioto");
KafkaStreams streams = new KafkaStreams(topology, props);
streams.start();

注意代码可以更简洁:我们可以创建自己的 Serde,其中包含反序列化代码,这样我们就可以直接将 Avro 对象反序列化为 HealthCheck 对象。为了实现这一点,这个类必须扩展通用的 Avro 反序列化器。我们将这个作为练习留给您。

运行 AvroStreamsProcessor

要构建项目,请在 kioto 目录下运行以下命令:

$ gradle build

如果一切正常,输出应类似于以下内容:

BUILD SUCCESSFUL in 1s
 6 actionable task: 6 up-to-date
  1. 第一步是运行 uptimes 主题的控制台消费者,如下所示:
 $ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 
      --topic uptimes --property print.key=true
  1. 从我们的 IDE 中运行 AvroStreamsProcessor 的主方法

  2. 从我们的 IDE 中运行 AvroProducer 的主方法(在之前的章节中构建)

  3. uptimes 主题的控制台消费者的输出应类似于以下内容:

 EW05-HV36 33
 BO58-SB28 20
 DV03-ZT93 46
 ... 

迟到事件处理

之前我们讨论了消息处理,但现在我们将讨论事件。在这个上下文中,事件是在特定时间发生的事情。事件是在某个时间点发生的信息。

为了理解事件,我们必须了解时间戳语义。一个事件总是有两个时间戳,如下所示:

  • 事件时间:事件在数据源发生的时间点

  • 处理时间:事件在数据处理器中被处理的时间点

由于物理定律的限制,处理时间将始终在事件时间之后,并且必然与事件时间不同,原因如下:

  • 网络延迟总是存在:从数据源到 Kafka 代理的传输时间不能为零。

  • 客户端可能有一个缓存:如果客户端之前缓存了一些事件,将它们发送到数据处理器。例如,考虑一个不是总是连接到网络的移动设备,因为有些区域没有网络访问,设备在发送数据之前会保存一些数据。

  • 背压的存在:有时,代理不会按到达顺序处理事件,因为它很忙,事件太多。

说到前面提到的几点,我们的消息带有时间戳始终很重要。自从 Kafka 的 0.10 版本以来,存储在 Kafka 中的消息总是有一个相关的时间戳。时间戳通常由生产者分配;如果生产者发送的消息没有时间戳,则代理会为其分配一个。

作为专业提示,在生成消息时,始终由生产者分配时间戳。

基本场景

为了解释迟到的事件,我们需要一个事件定期到达的系统,并且我们想知道每单位时间内产生了多少事件。在 图 6.1 中,我们展示了这个场景:

图片

图 6.1:事件的生产情况

在前面的图中,每个弹珠代表一个事件。它们不应该有维度,因为它们是在特定的时间点。事件是瞬时的,但为了演示目的,我们用球体来表示它们。正如我们在 t1t2 中所看到的,两个不同的事件可以同时发生。

在我们的图中,tn 代表第 n 个时间单位。每个弹珠代表一个单独的事件。为了区分它们,t1 上的事件有一条条纹,t2 上的事件有两条条纹,t3 上的事件有三条条纹。

我们想按单位时间计算事件,所以我们有以下:

  • t1 有六个事件

  • t2 有四个事件

  • t3 有三个事件

由于系统可能发生故障(例如网络延迟、服务器关闭、网络分区、电源故障、电压变化等),假设在 t2 发生的事件有一个延迟,并在 t3 时到达我们的系统,如下 图 6.2 所示:

图片

图 6.2:事件的处理过程

如果我们使用处理时间来计数事件,我们得到以下结果:

  • t1 有六个事件

  • t2 有三个事件

  • t3 有四个事件

如果我们必须计算每个时间单位产生的事件数量,我们的结果将是错误的。

t3 而不是 t2 到达的事件被称为延迟事件。我们只有两个选择,如下所示:

  • t2 结束时,生成一个初步结果,即 t2 的计数为三个事件。然后,在处理过程中,当我们发现另一个时间的事件属于 t2 时,我们更新 t2 的结果:t2 有四个事件。

  • 当每个窗口结束时,我们在生成结果之前会稍作等待。例如,我们可能再等待一个时间单位。在这种情况下,当 t(n+1) 结束时,我们获得 tn 的结果。记住,等待生成结果的时间可能并不与时间单位的大小相关。

如你所猜,这些场景在实践中相当常见,目前有许多有趣的提议。处理延迟事件最完整和先进的套件之一是 Apache Beam 提案。然而,Apache Spark、Apache Flink 和 Akka Streams 也非常强大和吸引人。

由于我们想看看如何使用 Kafka Streams 解决这个问题,让我们看看。

延迟事件生成

要测试 Kafka Streams 的延迟事件解决方案,我们首先需要一个延迟事件生成器。

为了简化问题,我们的生成器将以固定的速率不断发送事件。偶尔,它还会生成一个延迟事件。生成器按照以下过程生成事件:

  • 每个窗口长度为 10 秒

  • 它每秒产生一个事件

  • 事件应该在每分钟的 54 秒生成,并将延迟 12 秒;也就是说,它将在下一分钟的第六秒到达(在下一个窗口)

当我们说窗口是 10 秒时,我们的意思是我们将每 10 秒进行一次聚合。记住,测试的目标是确保延迟事件被计入正确的窗口。

创建 src/main/java/kioto/events 目录,并在其中创建一个名为 EventProducer.java 的文件,其内容如 列表 6.5 所示,如下所示:

package kioto.events;
import ...
public final class EventProducer {
  private final Producer<String, String> producer;
  private EventProducer(String brokers) {
    Properties props = new Properties();
    props.put("bootstrap.servers", brokers);
    props.put("key.serializer", StringSerializer.class);
    props.put("value.serializer", StringSerializer.class);
    producer = new KafkaProducer<>(props);
  }
  private void produce() {
    // ...
  }
  private void sendMessage(long id, long ts, String info) {
    // ...
  }
  public static void main(String[] args) {
    (new EventProducer("localhost:9092")).produce();
  }
}

列表 6.5:EventProducer.java

事件生成器是一个 Java KafkaProducer,因此需要声明与所有 Kafka Producers 相同的属性。

生成器代码非常简单,首先需要的是一个每秒生成一个事件的计时器。计时器在每秒后的 0.3 秒触发,以避免在 0.998 秒发送消息,例如。produce() 方法如下所示:

private void produce() {
  long now = System.currentTimeMillis();
  long delay = 1300 - Math.floorMod(now, 1000);
  Timer timer = new Timer();
  timer.schedule(new TimerTask() {
    public void run() {
      long ts = System.currentTimeMillis();
      long second = Math.floorMod(ts / 1000, 60);
      if (second != 54) {
        EventProducer.this.sendMessage(second, ts, "on time");
      }
      if (second == 6) {
        EventProducer.this.sendMessage(54, ts - 12000, "late");
      }
    }
  }, delay, 1000);
}

当计时器触发时,执行 run 方法。我们每秒发送一个事件,除了第 54 秒,我们延迟这个事件 12 秒。然后,我们在下一分钟的第六秒发送这个延迟的事件,修改时间戳。

sendMessage()方法中,我们只是分配事件的戳记,如下所示:

private void sendMessage(long id, long ts, String info) {
  long window = ts / 10000 * 10000;
  String value = "" + window + ',' + id + ',' + info;
  Future futureResult = this.producer.send(
     new ProducerRecord<>(
          "events", null, ts, String.valueOf(id), value));
  try {
    futureResult.get();
  } catch (InterruptedException | ExecutionException e) {
    // deal with the exception
  }
}

运行 EventProducer

要运行EventProducer,我们遵循以下步骤:

  1. 创建事件主题,如下所示:
$. /bin/kafka-topics --zookeeper localhost:2181 --create --topic 
events --replication-factor 1 --partitions 4
  1. 使用以下命令运行事件主题的控制台消费者:
$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 
--topic events
  1. 从 IDE 中运行EventProducer的 main 方法。

  2. 事件主题的控制台消费者的输出应类似于以下内容:

1532529060000,47, on time
1532529060000,48, on time
1532529060000,49, on time
1532529070000,50, on time
1532529070000,51, on time
1532529070000,52, on time
1532529070000,53, on time
1532529070000,55, on time
1532529070000,56, on time
1532529070000,57, on time
1532529070000,58, on time 
1532529070000,59, on time
1532529080000,0, on time
1532529080000,1, on time
1532529080000,2, on time
1532529080000,3, on time
1532529080000,4, on time
1532529080000,5, on time
1532529080000,6, on time
1532529070000,54, late
1532529080000,7, on time
...

注意,每个事件窗口每 10 秒变化一次。还要注意第 54 个事件在 53 个和 55 个事件之间没有发送。第 54 个事件属于前一个窗口,在下一分钟的第六秒和第七秒之间到达。

Kafka Streams 处理器

现在,让我们解决如何计算每个窗口中有多少事件的问题。为此,我们将使用 Kafka Streams。当我们进行此类分析时,我们称之为流聚合

src/main/java/kioto/events目录下,创建一个名为EventProcessor.java的文件,包含列表 6.6的内容,如下所示:

package kioto.events;
import ...
public final class EventProcessor {
  private final String brokers;
  private EventProcessor(String brokers) {
    this.brokers = brokers;
  }
  private void process() {
    // ...
  }
  public static void main(String[] args) {
    (new EventProcessor("localhost:9092")).process();
  }
}

列表 6.6:EventProcessor.java

所有的处理逻辑都包含在process()方法中。第一步是创建一个StreamsBuilder来创建KStream,如下所示:

StreamsBuilder streamsBuilder = new StreamsBuilder();
KStream stream = streamsBuilder.stream(
  "events", Consumed.with(Serdes.String(), Serdes.String()));

如我们所知,我们指定从主题读取事件,在这种情况下称为events,然后我们总是指定Serdes,键和值都是String类型。

如果你记得,我们每个步骤都是一个从一条流到另一条流的转换。

下一步是构建一个KTable。要做到这一点,我们首先使用groupBy()函数,它接收一个键值对,我们分配一个名为"foo"的键,因为它并不重要,但我们需要指定一个。然后,我们应用windowedBy()函数,指定窗口长度为 10 秒。最后,我们使用count()函数,因此我们产生键值对,其中键是String类型,值是long类型。这个数字是每个窗口的事件计数(键是窗口开始时间):

KTable aggregates = stream
  .groupBy( (k, v) -> "foo", Serialized.with(Serdes.String(), Serdes.String()))
  .windowedBy( TimeWindows.of(10000L) )
  .count( Materialized.with( Serdes.String(), Serdes.Long() ) );

如果你遇到关于KTable的概念可视化问题,比如哪些键是KTable<Windowed<String>>类型,值是long类型,并且打印它(在 KSQL 章节中我们将看到如何做),可能会像下面这样:

key | value
 ----------------- |-------
 1532529050000:foo | 10
 1532529060000:foo | 10
 1532529070000:foo | 9
 1532529080000:foo | 3
 ...

键包含窗口 ID 和具有值"foo"的实用聚合键。值是在特定时间点窗口中计数的元素数量。

接下来,由于我们需要将KTable输出到主题,我们需要将其转换为KStream,如下所示:

aggregates
  .toStream()
  .map( (ws, i) -> new KeyValue( ""+((Windowed)ws).window().start(), ""+i))
  .to("aggregates", Produced.with(Serdes.String(), Serdes.String()));

KTabletoStream()方法返回一个KStream。我们使用一个map()函数,该函数接收两个值,窗口和计数,然后我们提取窗口开始时间作为键,计数作为值。to()方法指定我们想要输出到哪个主题(始终指定 serdes 作为良好实践)。

最后,正如前几节所述,我们需要启动拓扑和应用,如下所示:

Topology topology = streamsBuilder.build();
Properties props = new Properties();
props.put("bootstrap.servers", this.brokers);
props.put("application.id", "kioto");
props.put("auto.offset.reset", "latest");
props.put("commit.interval.ms", 30000);
KafkaStreams streams = new KafkaStreams(topology, props);
streams.start();

记住,commit.interval.ms属性表示我们将等待多少毫秒将结果写入aggregates主题。

运行 Streams 处理器

运行EventProcessor,请按照以下步骤操作:

  1. 按照以下方式创建aggregates主题:
$. /bin/kafka-topics --zookeeper localhost:2181 --create --topic 
aggregates --replication-factor 1 --partitions 4
  1. 按照以下方式运行aggregates主题的控制台消费者:
$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 
--topic aggregates --property print.key=true
  1. 从 IDE 中运行EventProducer的 main 方法。

  2. 从 IDE 中运行EventProcessor的 main 方法。

  3. 记住,它每 30 秒向主题写入一次。aggregates主题的控制台消费者的输出应类似于以下内容:

1532529050000 10
1532529060000 10
1532529070000 9
1532529080000 3

在第二个窗口之后,我们可以看到KTable中的值使用新鲜(且正确)的数据进行了更新,如下所示:

1532529050000 10
1532529060000 10
1532529070000 10
1532529080000 10
1532529090000 10
1532529100000 4

注意在第一次打印中,最后一个窗口的值为 3,窗口在1532529070000开始,其值为9。然后在第二次打印中,值是正确的。这种行为是因为在第一次打印中,延迟的事件尚未到达。当这个事件最终到达时,所有窗口的计数值都被更正。

Streams 处理器分析

如果你在这里有很多问题,这是正常的。

首先要考虑的是,在流聚合和流处理中,Streams 是无界的。我们永远不清楚何时会得到最终结果,也就是说,作为程序员,我们必须决定何时将聚合的部分值视为最终结果。

回想一下,流的打印是某个时间点的KTable的瞬间快照。因此,KTable的结果仅在输出时有效。重要的是要记住,在未来,KTable的值可能会有所不同。现在,为了更频繁地看到结果,将提交间隔的值更改为零,如下所示:

props.put("commit.interval.ms", 0);

这行说明当KTable被修改时,将打印其结果,也就是说,它将每秒打印新值。如果你运行程序,KTable的值将在每次更新(每秒)时打印,如下所示:

1532529080000 6
1532529080000 7
1532529080000 8
1532529080000 9
1532529080000 10 <-- Window end
1532529090000 1  <-- Window beginning
1532529090000 2
1532529090000 3
1532529090000 5  <-- The 4th didn't arrive
1532529090000 6
1532529090000 7
1532529090000 8
1532529090000 9  <-- Window end
1532529100000 1
1532529100000 2
1532529100000 3
1532529100000 4
1532529100000 5
1532529100000 6
1532529090000 10 <-- The 4th arrived, so the count value is updated
1532529100000 7
1532529100000 8
...

注意以下两个效果:

  • 当窗口结束时,窗口的聚合结果(计数)停止在 9,并且下一个窗口的事件开始到达

  • 当延迟事件最终到达时,它会在窗口的计数中产生更新

是的,Kafka Streams 应用事件时间语义来进行聚合。重要的是要记住,为了可视化数据,我们必须修改提交间隔。将此值保留为零会对生产环境产生负面影响。

如你所猜,处理事件流比处理固定数据集要复杂得多。事件通常迟到,无序,实际上很难知道何时所有数据都已经到达。你如何知道有迟到的事件?如果有,我们应该期望它们有多少?何时应该丢弃一个迟到的事件?

程序员的质量取决于他们工具的质量。处理工具的能力在处理数据时会产生很大影响。在这种情况下,我们必须反思结果何时产生以及何时到达较晚。

丢弃事件的过程有一个特殊名称:水印。在 Kafka Streams 中,这是通过设置聚合窗口的保留期来实现的。

摘要

Kafka Streams 是一个强大的库,当使用 Apache Kafka 构建数据管道时,它是唯一的选择。Kafka Streams 移除了实现纯 Java 客户端时所需的大量样板工作。与 Apache Spark 或 Apache Flink 相比,Kafka Streams 应用程序构建和管理起来要简单得多。

我们也看到了如何改进 Kafka Streams 应用以反序列化 JSON 和 Avro 格式的数据。由于我们使用的是能够进行数据序列化和反序列化的 SerDes,因此序列化部分(写入主题)非常相似。

对于使用 Scala 的开发者来说,有一个名为 circe 的 Kafka Streams 库,它提供了 SerDes 来操作 JSON 数据。circe 库在 Scala 中相当于 Jackson 库。

如前所述,Apache Beam 拥有更复杂的工具集,但完全专注于流管理。其模型基于触发器和事件之间的语义。它还有一个强大的水印处理模型。

Kafka Streams 相比于 Apache Beam 的一个显著优势是其部署模型更为简单,这使得许多开发者倾向于采用它。然而,对于更复杂的问题,Apache Beam 可能是更好的工具。

在接下来的章节中,我们将讨论如何充分利用两个世界:Apache Spark 和 Kafka Streams。

第七章:KSQL

在前面的章节中,我们编写了 Java 代码来使用 Kafka 操作数据流,我们还为 Kafka 和 Kafka Streams 构建了几个 Java 处理器。在本章中,我们将使用 KSQL 来实现相同的结果。

本章涵盖了以下主题:

  • 简要介绍 KSQL

  • 运行 KSQL

  • 使用 KSQL CLI

  • 使用 KSQL 处理数据

  • 向主题写入

简要介绍 KSQL

使用 Kafka Connect,我们可以在多种编程语言中构建客户端:JVM(Java、Clojure、Scala)、C/C++、C#、Python、Go、Erlang、Ruby、Node.js、Perl、PHP、Rust 和 Swift。除此之外,如果你的编程语言不在列表中,你可以使用 Kafka REST 代理。但 Kafka 开发者意识到,所有程序员,尤其是数据工程师,都可以使用同一种语言:结构化查询语言SQL)。因此,他们决定在 Kafka Streams 上创建一个抽象层,在这个抽象层中,他们可以使用 SQL 来操作和查询流。

KSQL 是 Apache Kafka 的 SQL 引擎。它允许编写 SQL 语句以实时分析数据流。请记住,流是一个无界的数据结构,所以我们不知道它从哪里开始,我们一直在接收新的数据。因此,KSQL 查询通常会持续生成结果,直到你停止它们。

KSQL 在 Kafka Streams 上运行。要在一个数据流上运行查询,查询会被解析、分析,然后构建并执行一个 Kafka Streams 拓扑,就像我们在运行 Kafka Streams 应用程序时在每个 process() 方法结束时所做的那样。KSQL 已经将 Kafka Streams 的概念一一映射,例如,表、连接、流、窗口函数等。

KSQL 在 KSQL 服务器上运行。因此,如果我们需要更多容量,我们可以运行一个或多个 KSQL 服务器实例。内部,所有 KSQL 实例协同工作,通过一个称为 _confluent-ksql-default__command_topic 的专用和私有主题发送和接收信息。

与所有 Kafka 技术一样,我们也可以通过 REST API 与 KSQL 交互。此外,KSQL 还拥有自己的精美 命令行界面CLI)。如果您想了解更多关于 KSQL 的信息,请阅读以下 URL 上的在线文档:docs.confluent.io/current/ksql/docs/index.html

运行 KSQL

如前所述,KSQL 随 Confluent 平台一起提供。当我们启动 Confluent 平台时,它会在结束时自动启动一个 KSQL 服务器,如图 7.1 所示:

图片

图 7.1:Confluent 平台启动

要单独启动 KSQL 服务器(不推荐),我们可以使用 ksql-server-start 命令。只需从 bin 目录中输入 ./ksql,如图 7.2 所示:

图片

图 7.2:KSQL CLI 启动屏幕

使用 KSQL CLI

KSQL CLI 是一个用于与 KSQL 交互的命令提示符;它与 MariaDB 或 MySQL 等关系型数据库附带的命令提示符非常相似。要查看所有可能的命令,请输入 help,将显示一个带有选项的列表。

目前,我们还没有向 KSQL 通知任何内容。我们必须声明某个东西是一个表或一个流。我们将使用前面章节中生产者写入 JSON 信息到 healthchecks 主题产生的信息。

如果你还记得,数据看起来是这样的:

{"event":"HEALTH_CHECK","factory":"Lake Anyaport","serialNumber":"EW05-HV36","type":"WIND","status":"STARTING","lastStartedAt":"2018-09-17T11:05:26.094+0000","temperature":62.0,"ipAddress":"15.185.195.90"}
{"event":"HEALTH_CHECK","factory":"Candelariohaven","serialNumber":"BO58-SB28","type":"SOLAR","status":"STARTING","lastStartedAt":"2018-08-16T04:00:00.179+0000","temperature":75.0,"ipAddress":"151.157.164.162"}
{"event":"HEALTH_CHECK","factory":"Ramonaview","serialNumber":"DV03-ZT93","type":"SOLAR","status":"RUNNING","lastStartedAt":"2018-07-12T10:16:39.091+0000","temperature":70.0,"ipAddress":"173.141.90.85"}
...

KSQL 可以读取 JSON 数据,也可以读取 Avro 格式的数据。要从 healthchecks 主题声明一个流,我们使用以下命令:

ksql>  CREATE STREAM healthchecks (event string, factory string, serialNumber string, type string, status string, lastStartedAt string, temperature double, ipAddress string) WITH (kafka_topic='healthchecks', value_format='json');

输出类似于以下内容:

Message
----------------------------
Stream created and running
----------------------------

要查看现有 STREAM 的结构,我们可以使用 DESCRIBE 命令,如下所示,它告诉我们数据类型及其结构:

ksql> DESCRIBE healthchecks;

输出类似于以下内容:

Name          : HEALTHCHECKS
Field         | Type
-------------------------------------------
ROWTIME       | BIGINT           (system)
ROWKEY        | VARCHAR(STRING)  (system)
EVENT         | VARCHAR(STRING)
FACTORY       | VARCHAR(STRING)
SERIALNUMBER  | VARCHAR(STRING)
TYPE          | VARCHAR(STRING)
STATUS        | VARCHAR(STRING)
LASTSTARTEDAT | VARCHAR(STRING)
TEMPERATURE   | DOUBLE
IPADDRESS     | VARCHAR(STRING)

注意,一开始会显示两个额外的字段:ROWTIME(消息时间戳)和ROWKEY(消息键)。

当我们创建流时,我们声明 Kafka 主题是 healthchecks。因此,如果我们执行 SELECT 命令,我们将获得一个列表,其中包含实时指向我们的流所在主题的事件(记得运行一个生产者以获取新鲜数据)。命令如下:

ksql> select * from healthchecks;

输出类似于以下内容:

1532598615943 | null | HEALTH_CHECK | Carliefort | FM41-RE80 | WIND | STARTING | 2017-08-13T09:37:21.681+0000 | 46.0 | 228.247.233.14
1532598616454 | null | HEALTH_CHECK | East Waldo | HN72-EB29 | WIND | RUNNING | 2017-10-31T14:20:13.929+0000 | 3.0 | 223.5.127.146
1532598616961 | null | HEALTH_CHECK | New Cooper | MM04-TZ21 | SOLAR | SHUTTING_DOWN | 2017-08-21T21:10:31.190+0000 | 23.0 | 233.143.140.46
1532598617463 | null | HEALTH_CHECK | Mannmouth | XM02-PQ43 | GEOTHERMAL | RUNNING | 2017-09-08T10:44:56.005+0000 | 73.0 | 221.96.17.237
1532598617968 | null | HEALTH_CHECK | Elvisfort | WP70-RY81 | NUCLEAR | RUNNING | 2017-09-07T02:40:18.917+0000 | 49.0 | 182.94.17.58
1532598618475 | null | HEALTH_CHECK | Larkinstad | XD75-FY56 | GEOTHERMAL | STARTING | 2017-09-06T08:48:14.139+0000 | 35.0 | 105.236.9.137
1532598618979 | null | HEALTH_CHECK | Nakiaton | BA85-FY32 | SOLAR | RUNNING | 2017-08-15T04:10:02.590+0000 | 32.0 | 185.210.26.215
1532598619483 | null | HEALTH_CHECK | North Brady | NO31-LM78 | HYDROELECTRIC | RUNNING | 2017-10-05T12:12:52.940+0000 | 5.0 | 17.48.190.21
1532598619989 | null | HEALTH_CHECK | North Josianemouth | GT17-TZ11 | SOLAR | SHUTTING_DOWN | 2017-08-29T16:57:23.000+0000 | 6.0 | 99.202.136.163

SELECT 命令显示了流中声明的 Kafka 主题的数据。查询永远不会停止,所以它会一直运行,直到你停止它。新记录以新行打印,因为主题中新的事件被产生。要停止查询,请输入 Ctrl + C

使用 KSQL 处理数据

在前面的章节中,我们从 healthchecks 主题获取数据,计算机器的 uptimes,并将这些数据推送到一个名为 uptimes 的主题。现在,我们将使用 KSQL 来做这件事。

在编写本文时,KSQL 还没有比较两个日期的函数,所以我们有以下两种选择:

  • 为 KSQL 编写一个用户自定义函数UDF)的 Java 代码

  • 使用现有的函数来进行我们的计算

由于现在创建新的 UDF 不在范围之内,让我们选择第二个选项:使用现有的函数来进行我们的计算。

第一步是使用 STRINGTOTIMESTAMP 函数解析启动时间,如下所示(记住我们以字符串格式声明了日期,因为 KSQL 还没有 DATE 类型):

ksql> SELECT event, factory, serialNumber, type, status, lastStartedAt, temperature, ipAddress, STRINGTOTIMESTAMP(lastStartedAt,'yyyy-MM-dd''T''HH:mm:ss.SSSZ') FROM healthchecks;

输出类似于以下内容:

HEALTH_CHECK | Ezekielfurt | AW90-DQ16 | HYDROELECTRIC | RUNNING | 2017-09-28T21:00:45.683+0000 | 7.0 | 89.87.184.250 | 1532168445683
HEALTH_CHECK | Icieville | WB52-WC16 | WIND | SHUTTING_DOWN | 2017-10-31T22:38:26.783+0000 | 15.0 | 40.23.168.167 | 1532025506783
HEALTH_CHECK | McClurehaven | QP68-WX17 | GEOTHERMAL | RUNNING | 2017-11-12T23:16:27.105+0000 | 76.0 | 252.213.150.75 | 1532064587105
HEALTH_CHECK | East Maudshire | DO15-BB56 | NUCLEAR | STARTING | 2017-10-14T03:04:00.399+0000 | 51.0 | 93.202.28.134 | 1532486240399
HEALTH_CHECK | South Johnhaven | EE06-EX06 | HYDROELECTRIC | RUNNING | 2017-09-06T20:14:27.438+0000 | 91.0 | 244.254.181.218 | 1532264867438

下一步是将这些日期与当前日期进行比较。在 KSQL 中,目前还没有获取今天日期的函数,所以让我们使用 STRINGTOTIMESTAMP 函数来解析今天的日期,如下所示:

ksql> SELECT serialNumber, STRINGTOTIMESTAMP(lastStartedAt,'yyyy-MM-dd''T''HH:mm:ss.SSSZ'), STRINGTOTIMESTAMP('2017-11-18','yyyy-MM-dd') FROM healthchecks;

输出类似于以下内容:

FE79-DN10 | 1532050647607 | 1510984800000
XE79-WP47 | 1532971000830 | 1510984800000
MP03-XC09 | 1532260107928 | 1510984800000
SO48-QF28 | 1532223768121 | 1510984800000
OC25-AB61 | 1532541923073 | 1510984800000
AL60-XM70 | 1532932441768 | 1510984800000

现在,让我们比较这两个日期,并计算它们之间的天数,如下所示(1 天 = 86,400 秒 = 24 小时 x 60 分钟 x 60 秒,1 秒 = 1,000 毫秒):

ksql> SELECT serialNumber, (STRINGTOTIMESTAMP('2017-11-18','yyyy-MM-dd''T''HH:mm:ss.SSSZ')-STRINGTOTIMESTAMP(lastStartedAt,'yyyy-MM-dd'))/86400/1000 FROM healthchecks;

输出类似于以下内容:

EH92-AQ09 | 39
BB09-XG98 | 42
LE94-BT50 | 21
GO25-IE91 | 97
WD93-HP20 | 22
JX48-KN03 | 12
EC84-DD11 | 73
SF06-UB22 | 47
IU77-VQ89 | 18
NM80-ZY31 | 5
TR64-TI21 | 51
ZQ13-GI11 | 80
II04-MB66 | 48

完美,现在我们已经为每台机器计算了运行时间。

向主题写入

到目前为止,我们已经处理了数据并在实时中打印了结果。要将这些结果发送到另一个主题,我们使用CREATE命令模式,其中指定来自SELECT

让我们先以字符串形式编写 uptime,并以逗号分隔的格式写入数据,如下所示(记住 KSQL 支持逗号分隔、JSON 和 Avro 格式)。目前,这已经足够,因为我们只写入一个值:

ksql> CREATE STREAM uptimes WITH (kafka_topic='uptimes', value_format='delimited') AS SELECT CAST((STRINGTOTIMESTAMP('2017-11-18','yyyy-MM-dd''T''HH:mm:ss.SSSZ')-STRINGTOTIMESTAMP(lastStartedAt,'yyyy-MM-dd'))/86400/1000 AS string) AS uptime FROM healthchecks;

输出类似于以下内容:

Message
----------------------------
Stream created and running
----------------------------

我们的查询正在后台运行。要查看它是否在运行,我们可以使用uptimes主题的控制台消费者,如下所示:

$ ./kafka-console-consumer --bootstrap-server localhost:9092 --topic uptimes --property print.key=true

输出类似于以下内容:

null  39
null  42
null  21

结果是正确的;然而,我们忘记使用机器序列号作为消息键。为此,我们必须重建我们的查询和流。

第一步是使用show queries命令,如下所示:

ksql> show queries;

输出类似于以下内容:

 Query ID       | Kafka Topic | Query String
-------------------------------------------------------------------------------
CSAS_UPTIMES_0 | UPTIMES     | CREATE STREAM uptimes WITH (kafka_topic='uptimes', value_format='delimited') AS SELECT CAST((STRINGTOTIMESTAMP('2017-11-18','yyyy-MM-dd''T''HH:mm:ss.SSSZ')-STRINGTOTIMESTAMP(lastStartedAt,'yyyy-MM-dd'))/86400/1000 AS string) AS uptime FROM healthchecks;
-------------------------------------------------------------------------------
For detailed information on a Query run: EXPLAIN <Query ID>;

使用查询 ID,使用terminate <ID>命令,如下所示:

ksql> terminate CSAS_UPTIMES_0;

输出类似于以下内容:

Message
-------------------
Query terminated.
-------------------

要删除流,使用DROP STREAM命令,如下所示:

ksql> DROP STREAM uptimes;

输出类似于以下内容:

Message
------------------------------
Source UPTIMES was dropped.
------------------------------

要正确写入事件键,我们必须使用PARTITION BY子句。首先,我们使用部分计算重新生成我们的流,如下所示:

ksql> CREATE STREAM healthchecks_processed AS SELECT serialNumber, CAST((STRINGTOTIMESTAMP('2017-11-18','yyyy-MM-dd''T''HH:mm:ss.SSSZ')-STRINGTOTIMESTAMP(lastStartedAt,'yyyy-MM-dd'))/86400/1000 AS string) AS uptime FROM healthchecks;

输出类似于以下内容:

Message
----------------------------
Stream created and running
----------------------------

此流有两个字段(serialNumberuptime)。要将这些计算值写入主题,我们使用CREATE STREAMAS SELECT如下所示:

ksql> CREATE STREAM uptimes WITH (kafka_topic='uptimes', value_format='delimited') AS SELECT * FROM healthchecks_processed;

输出类似于以下内容:

Message
----------------------------
Stream created and running
----------------------------

最后,运行控制台消费者以显示结果,如下所示:

$ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 --topic uptimes --property print.key=true

输出类似于以下内容:

EW05-HV36   33
BO58-SB28   20
DV03-ZT93   46
...

现在,关闭 KSQL CLI(Ctrl + C 并关闭命令窗口)。由于查询仍在 KSQL 中运行,你仍然可以在控制台消费者窗口中看到输出。

恭喜你,你已经使用几个 KSQL 命令构建了一个 Kafka Streams 应用程序。

要揭示 KSQL 的全部功能,重要的是要查看以下地址的官方文档:

docs.confluent.io/current/ksql/docs/tutorials/index.html

摘要

KSQL 仍然非常新,但该产品已经在开发者中获得了采用。我们都希望它继续扩展以支持更多数据格式(如 Protobuffers、Thrift 等)和更多功能(如地理定位和物联网等非常有用的更多 UDFs)。

因此,再次恭喜!在本章中,我们与之前一样,但没有写一行 Java 代码。这使得 KSQL 成为非程序员但致力于数据分析的人们的首选工具。

第八章:Kafka Connect

在本章中,我们不是使用 Kafka Java API 进行生产者和消费者,也不是使用 Kafka Streams 或 KSQL(如前几章所述),我们将使用 Spark Structured Streaming 连接 Kafka,这是 Apache Spark 用于处理流数据的解决方案,它使用其 Datasets API。

本章涵盖了以下主题:

  • Spark Streaming 处理器

  • 从 Spark 读取 Kafka

  • 数据转换

  • 数据处理

  • 从 Spark 写入 Kafka

  • 运行SparkProcessor

Kafka Connect 概述

Kafka Connect 是一个开源框架,是 Apache Kafka 的一部分;它用于将 Kafka 与其他系统连接起来,例如结构化数据库、列存储、键值存储、文件系统和搜索引擎。

Kafka Connect 拥有广泛的内置连接器。如果我们从外部系统读取,它被称为数据源;如果我们向外部系统写入,它被称为数据接收器

在前几章中,我们创建了一个 Java Kafka 生产者,它以如下三条消息的形式将 JSON 数据发送到名为healthchecks的主题。

{"event":"HEALTH_CHECK","factory":"Lake Anyaport","serialNumber":"EW05-HV36","type":"WIND","status":"STARTING","lastStartedAt":"2018-09-17T11:05:26.094+0000","temperature":62.0,"ipAddress":"15.185.195.90"}
{"event":"HEALTH_CHECK","factory":"Candelariohaven","serialNumber":"BO58-SB28","type":"SOLAR","status":"STARTING","lastStartedAt":"2018-08-16T04:00:00.179+0000","temperature":75.0,"ipAddress":"151.157.164.162"}{"event":"HEALTH_CHECK","factory":"Ramonaview","serialNumber":"DV03-ZT93","type":"SOLAR","status":"RUNNING","lastStartedAt":"2018-07-12T10:16:39.091+0000","temperature":70.0,"ipAddress":"173.141.90.85"}
...

现在,我们将处理这些数据以计算机器的运行时间和获取包含如下三条消息的主题:

EW05-HV36   33
BO58-SB28   20
DV03-ZT93   46
...

项目设置

第一步是修改我们的 Kioto 项目。我们必须在build.gradle中添加依赖项,如下所示:

apply plugin: 'java'
apply plugin: 'application'
sourceCompatibility = '1.8'
mainClassName = 'kioto.ProcessingEngine'
repositories {
    mavenCentral()
    maven { url 'https://packages.confluent.io/maven/' }
}
version = '0.1.0'
dependencies {
    compile 'com.github.javafaker:javafaker:0.15'
    compile 'com.fasterxml.jackson.core:jackson-core:2.9.7'
    compile 'io.confluent:kafka-avro-serializer:5.0.0'
    compile 'org.apache.kafka:kafka_2.12:2.0.0'
    compile 'org.apache.kafka:kafka-streams:2.0.0'
    compile 'io.confluent:kafka-streams-avro-serde:5.0.0'
    compile 'org.apache.spark:spark-sql_2.11:2.2.2'
    compile 'org.apache.spark:spark-sql-kafka-0-10_2.11:2.2.2'
}
jar {
    manifest {
        attributes 'Main-Class': mainClassName
    } from {
        configurations.compile.collect {
            it.isDirectory() ? it : zipTree(it)
        }
    }
    exclude "META-INF/*.SF"
    exclude "META-INF/*.DSA"
    exclude "META-INF/*.RSA"
}

列表 8.1:Kioto gradle 构建文件用于 Spark

要使用 Apache Spark,我们需要以下依赖项:

compile 'org.apache.spark:spark-sql_2.11:2.2.2'

要连接 Apache Spark 与 Kafka,我们需要以下依赖项:

compile 'org.apache.spark:spark-sql-kafka-0-10_2.11:2.2.2'

我们使用旧版本的 Spark,2.2.2,以下两个原因:

  • 当你阅读这段内容时,Spark 版本肯定已经更新了。我选择这个版本(而不是写作时的最后一个版本)的原因是,与 Kafka 的连接器在这个版本上工作得非常好(在性能和错误方面)。

  • 与此版本一起工作的 Kafka 连接器比最现代的 Kafka 连接器版本落后几个版本。在升级生产环境时,你始终必须考虑这一点

Spark Streaming 处理器

现在,在src/main/java/kioto/spark目录中,创建一个名为SparkProcessor.java的文件,其内容如列表 8.2 所示,如下所示:

package kioto.spark;
import kioto.Constants;
import org.apache.spark.sql.*;
import org.apache.spark.sql.streaming.*;
import org.apache.spark.sql.types.*;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.Period;

public class SparkProcessor {
  private String brokers;
  public SparkProcessor(String brokers) {
    this.brokers = brokers;
  }
  public final void process() {
    //below is the content of this method
  }
  public static void main(String[] args) {
    (new SparkProcessor("localhost:9092")).process();
  }
}

列表 8.2:SparkProcessor.java

注意,与之前的示例一样,主方法使用 Kafka 代理的 IP 地址和端口号调用了process()方法。

现在,让我们填充process()方法。第一步是初始化 Spark,如下面的代码块所示:

SparkSession spark = SparkSession.builder()
    .appName("kioto")
    .master("local[*]")
    .getOrCreate();

在 Spark 中,集群中每个成员的应用名称必须相同,因此这里我们称之为 Kioto(原始的,不是吗?)。

由于我们打算在本地运行应用程序,我们将 Spark master 设置为local[*],这意味着我们正在创建与机器 CPU 核心数量相等的线程。

从 Spark 读取 Kafka

Apache Spark 有几个连接器。在这种情况下,我们使用 Databricks Inc.(Apache Spark 的负责公司)的 Kafka 连接器。

使用这个 Spark Kafka 连接器,我们可以从 Kafka 主题中读取数据,使用 Spark Structured Streaming:

 Dataset<Row> inputDataset = spark
    .readStream()
    .format("kafka")
    .option("kafka.bootstrap.servers", brokers)
    .option("subscribe", Constants.getHealthChecksTopic())
    .load();

只需说 Kafka 格式,我们就可以从subscribe选项指定的主题中读取流,在指定的代理上运行。

在代码的这个位置,如果你在inputDataSet上调用printSchema()方法,结果将类似于图 8.1

图 8.1:打印模式输出

我们可以这样理解:

  • 键和值都是二进制数据。在这里,不幸的是,与 Kafka 不同,Spark 中无法指定我们的数据反序列化器。因此,有必要使用 Dataframe 操作来进行反序列化。

  • 对于每条消息,我们可以知道主题、分区、偏移量和时间戳。

  • 时间戳类型始终为零。

与 Kafka Streams 一样,在 Spark Streaming 中,在每一步我们都必须生成一个新的数据流,以便应用转换并获得新的数据流。

在每个步骤中,如果我们需要打印我们的数据流(用于调试应用程序),我们可以使用以下代码:

StreamingQuery consoleOutput =
    streamToPrint.writeStream()
    .outputMode("append")
    .format("console")
    .start();

第一行是可选的,因为我们实际上不需要将结果分配给一个对象,只需要代码执行。

这个片段的输出类似于图 8.2。消息值肯定是二进制数据:

图 8.2:数据流控制台输出

数据转换

我们知道,当我们产生数据时,它是以 JSON 格式,尽管 Spark 以二进制格式读取它。为了将二进制消息转换为字符串,我们使用以下代码:

Dataset<Row> healthCheckJsonDf =
    inputDataset.selectExpr("CAST(value AS STRING)");

Dataset控制台输出现在是可读的,如下所示:

+--------------------------+
|                     value|
+--------------------------+
| {"event":"HEALTH_CHECK...|
+--------------------------+

下一步是提供字段列表以指定 JSON 消息的数据结构,如下所示:

StructType struct = new StructType()
    .add("event", DataTypes.StringType)
    .add("factory", DataTypes.StringType)
    .add("serialNumber", DataTypes.StringType)
    .add("type", DataTypes.StringType)
    .add("status", DataTypes.StringType)
    .add("lastStartedAt", DataTypes.StringType)
    .add("temperature", DataTypes.FloatType)
    .add("ipAddress", DataTypes.StringType);

接下来,我们反序列化 JSON 格式的 String。最简单的方法是使用org.apache.spark.sql.functions包中预构建的from_json()函数,如下所示:

Dataset<Row> healthCheckNestedDs =
    healthCheckJsonDf.select(
        functions.from_json(
            new Column("value"), struct).as("healthCheck"));

如果我们在这个时候打印Dataset,我们可以看到列嵌套正如我们在模式中指示的那样:

root
 |-- healthcheck: struct (nullable = true)
 |    |-- event: string (nullable = true)
 |    |-- factory: string (nullable = true)
 |    |-- serialNumber: string (nullable = true)
 |    |-- type: string (nullable = true)
 |    |-- status: string (nullable = true)
 |    |-- lastStartedAt: string (nullable = true)
 |    |-- temperature: float (nullable = true)
 |    |-- ipAddress: string (nullable = true)

下一步是将这个Dataset展开,如下所示:

Dataset<Row> healthCheckFlattenedDs = healthCheckNestedDs
   .selectExpr("healthCheck.serialNumber", "healthCheck.lastStartedAt");

为了可视化展开,如果我们打印Dataset,我们得到以下内容:

root
 |-- serialNumber: string (nullable = true)
 |-- lastStartedAt: string (nullable = true)

注意,我们以字符串的形式读取启动时间。这是因为内部from_json()函数使用了 Jackson 库。不幸的是,没有方法可以指定要读取的日期格式。

幸运的是,为了这些目的,同一个函数包中有一个to_timestamp()函数。如果只需要读取日期,忽略时间指定,还有一个to_date()函数。在这里,我们正在重写lastStartedAt列,类似于以下内容:

Dataset<Row> healthCheckDs = healthCheckFlattenedDs
    .withColumn("lastStartedAt", functions.to_timestamp(
        new Column ("lastStartedAt"), "yyyy-MM-dd'T'HH:mm:ss.SSSZ"));

数据处理

现在,我们要做的是计算uptimes。正如预期的那样,Spark 没有内置函数来计算两个日期之间的天数,因此我们将创建一个用户定义的函数。

如果我们记得 KSQL 章节,我们也可以在 KSQL 中构建和使用新的 UDFs。

要实现这一点,我们首先构建一个函数,该函数接收一个java.sql.Timestamp作为输入,如下所示(这是 Spark DataSets 中表示时间戳的方式)并返回一个表示从该日期起的天数的整数:

private final int uptimeFunc(Timestamp date) {
    LocalDate localDate = date.toLocalDateTime().toLocalDate();
    return Period.between(localDate, LocalDate.now()).getDays();
}

下一步是生成一个 Spark UDF,如下所示:

Dataset<Row> processedDs = healthCheckDs
    .withColumn( "lastStartedAt", new Column("uptime"));

最后,将该 UDF 应用到lastStartedAt列以在Dataset中创建一个名为uptime的新列。

从 Spark 写入 Kafka

由于我们已经处理了数据并计算了uptime,现在我们只需要将这些值写入名为uptimes的 Kafka 主题。

Kafka 的连接器允许我们将值写入 Kafka。要求是写入的Dataset必须有一个名为key的列和一个名为value的列;每个都可以是 String 或二进制类型。

由于我们希望机器序列号作为键,如果它已经是 String 类型就没有问题。现在,我们只需要将uptime列从二进制转换为 String。

我们使用Dataset类的select()方法来计算这两个列,并使用as()方法给它们赋予新的名称,如下所示(为此,我们也可以使用该类中的alias()方法):

Dataset<Row> resDf = processedDs.select(
    (new Column("serialNumber")).as("key"),
    processedDs.col("uptime").cast(DataTypes.StringType).as("value"));

我们的Dataset已经准备好了,并且它符合 Kafka 连接器预期的格式。以下代码是告诉 Spark 将这些值写入 Kafka:

//StreamingQuery kafkaOutput =
resDf.writeStream()
   .format("kafka")
   .option("kafka.bootstrap.servers", brokers)
   .option("topic", "uptimes")
   .option("checkpointLocation", "/temp")
   .start();

注意我们在选项中添加了 checkpoint 位置。这是为了确保 Kafka 的高可用性。然而,这并不保证消息以精确一次模式传递。如今,Kafka 可以保证精确一次传递;而 Spark 目前只能保证至少一次传递模式。

最后,我们调用awaitAnyTermination()方法,如下所示:

try {
  spark.streams().awaitAnyTermination();
} catch (StreamingQueryException e) {
  // deal with the Exception
}

重要的一点是提到,如果 Spark 在代码中留下控制台输出,这意味着所有查询必须在调用任何awaitTermination()方法之前调用其start()方法,如下所示:

firstOutput = someDataSet.writeStream
...
    .start()
...
 secondOutput = anotherDataSet.writeStream
...
    .start()
firstOutput.awaitTermination()
anotherOutput.awaitTermination()

还要注意,我们可以将末尾的所有awaitTermination()调用替换为对awaitAnyTermination()的单次调用,就像我们在原始代码中所做的那样。

运行 SparkProcessor

要构建项目,请从kioto目录运行以下命令:

$ gradle jar

如果一切正常,输出类似于以下内容:

BUILD SUCCESSFUL in 3s
1 actionable task: 1 executed
  1. 从命令行终端,移动到Confluent目录并按如下方式启动:
 $ ./bin/confluent start
  1. 运行uptimes主题的控制台消费者,如下所示:
 $ ./bin/kafka-console-consumer --bootstrap-server localhost:9092 
      --topic uptimes
  1. 从我们之前的章节中构建的PlainProducer的 IDE 中运行主方法

  2. 生产者的控制台消费者输出应类似于以下内容:

{"event":"HEALTH_CHECK","factory":"Lake Anyaport","serialNumber":"EW05-HV36","type":"WIND","status":"STARTING","lastStartedAt":"2017-09-17T11:05:26.094+0000","temperature":62.0,"ipAddress":"15.185.195.90"}
{"event":"HEALTH_CHECK","factory":"Candelariohaven","serialNumber":"BO58-SB28","type":"SOLAR","status":"STARTING","lastStartedAt":"2017-08-16T04:00:00.179+0000","temperature":75.0,"ipAddress":"151.157.164.162"}
{"event":"HEALTH_CHECK","factory":"Ramonaview","serialNumber":"DV03-ZT93","type":"SOLAR","status":"RUNNING","lastStartedAt":"2017-07-12T10:16:39.091+0000","temperature":70.0,"ipAddress":"173.141.90.85"}
...
  1. 从我们的 IDE 中运行SparkProcessor的主方法

  2. uptimes主题的控制台消费者的输出应类似于以下内容:

 EW05-HV36   33
 BO58-SB28   20
 DV03-ZT93   46
 ...

摘要

如果你是一个使用 Spark 进行批量处理的人,Spark Structured Streaming 是你应该尝试的工具,因为它的 API 与其批量处理对应工具类似。

现在,如果我们比较 Spark 和 Kafka 在流处理方面的表现,我们必须记住 Spark Streaming 是为了处理吞吐量而设计的,而不是延迟,处理低延迟的流变得非常复杂。

Spark Kafka 连接器一直是一个复杂的问题。例如,我们必须使用两者的旧版本,因为随着每个新版本的发布,两边都有太多的变化。

在 Spark 中,部署模型总是比 Kafka Streams 复杂得多。尽管 Spark、Flink 和 Beam 可以执行比 Kafka Streams 更复杂的任务,但学习和实现起来最简单的始终是 Kafka。

posted @ 2025-09-11 09:45  绝不原创的飞龙  阅读(65)  评论(0)    收藏  举报