Camel-云原生集成教程-二-

97 阅读21分钟

Camel 云原生集成教程(二)

原文:Cloud Native Integration with Apache Camel

协议:CC BY-NC-SA 4.0

四、使用 Apache Camel 访问数据库

我们在实现 API 或集成时所做的大部分工作是移动数据。我们提供数据,消费数据,转换数据,复制数据,等等。这样,我们可以让前端系统或自动化系统完成它们的任务。为了在我们的路由中提供这种能力,在某些时候,我们将需要持久化数据,或者从专门从事持久化和数据搜索的系统中读取数据。通常它会涉及到数据库。

现在有大量的各种各样的数据库。我们有传统的 SQL 和表格数据库,我们有面向文档的数据库,我们有图形数据库,等等。它们中的每一个都适合特定的用例,对于每一个用例,都有不同的解决问题的可能性。这是我在本章中不打算越过的界限。Camel 提供了各种各样的组件来访问大多数类型的数据库。我无法在这一章中涵盖所有的内容。所以这里我们将关注关系数据库,因为它们有更标准化的访问方法,并且仍然是最常见的用例之一。

从集成的角度来看,我希望您知道如何使用 Camel 访问数据库,以及您需要在 Quarkus 上进行的配置,这样当您遇到数据库集成案例时,您将知道如何使用这些工具访问它。您将看到维护数据一致性的机制以及如何处理应用异常。

像往常一样,当我们讨论本章的主要主题时,我们将参与关于集成模式的讨论。你还会学到新的 Camel 概念。

关系数据库

我们从关系数据库开始,这是最常见的数据库使用案例。您将看到如何利用 Java 数据库连接规范(JDBC)和 Jakarta 持久性 API (JPA ),通过 Quarkus 和 Camel 与数据库进行交互。

这本书涵盖了很多不同的技术和用例,因为 Camel 是一个非常广泛的工具。我想让您了解 Camel 是如何工作的,它的概念是什么,同时也给你一些最常见用例的实际例子。连接到关系数据库出现在十大常见用例列表中。

因为我们讨论了许多不同的主题,所以方法总是去寻找最常见的需求和特性,这些需求和特性展示了特定组件是如何工作的,以及为什么应该使用它。我会给你一个基本的例子,展示如何解决它。从那以后,你可以继续你的研究,尝试实现更复杂的用例。这一章也不例外。

我希望您已经熟悉了关系数据库,尤其是 Java 如何处理它们,但是如果您是 Java 初学者,请不要担心。你仍然能够执行本书中的例子,并且理解正在做的事情。

不再拖延,让我们看看例子。

JPA 的持久性

您需要一个用例来演示如何在 Camel 中使用 JDBC 和 JPA,所以让我们使用一个您已经知道的用例:联系人列表 API。让我们用一个实际的数据库替换内存中的对象集。

首先在 IDE 中打开contact-list-api-jpa项目。首先,您必须分析本例中使用的扩展。看清单 4-1 中的pom.xml

...
<dependencies>
  <dependency>
    <groupId>org.apache.camel.quarkus</groupId>
    <artifactId>camel-quarkus-rest</artifactId>
  </dependency>
  <dependency>
    <groupId>org.apache.camel.quarkus</groupId>
    <artifactId>camel-quarkus-jackson</artifactId>
  </dependency>
  <dependency>
    <groupId>org.apache.camel.quarkus</groupId>
    <artifactId>camel-quarkus-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-jdbc-h2</artifactId>
  </dependency>
</dependencies>
...

Listing 4-1Project contact-list-api-jpa pom.xml Snippet

你已经知道的前两个依赖项。您使用了camel-quarkus-rest来提供其余的 DSL 和camel-quarkus-jackson,这样您就可以自动绑定传入和传出的 JSON。另外三个将帮助您进行关系数据库连接。

您可能已经注意到,quarkus-jdbc-h2有一个不同于其他依赖项的组 id。这个库不是 Camel 组件,而是一个 Quarkus 扩展,为给定的数据库提供 JDBC 驱动程序,并允许配置 Camel 组件将使用的数据源,在本例中为camel-quarkus-jpa。我们一会儿会谈到它。

让我们检查一下路线是如何变化的。看清单 4-2 。

public class ContactListRoute extends RouteBuilder {

public static final String MEDIA_TYPE_APP_JSON = "application/json";

@Override
public void configure() throws Exception {

  rest("/contact")
     .bindingMode(RestBindingMode.json)
    .post()
      .type(Contact.class)
      .outType(Contact.class)
      .consumes(MEDIA_TYPE_APP_JSON)
      .produces(MEDIA_TYPE_APP_JSON)
      .route()
         .routeId("save-contact-route")
         .log("saving contacts")
         .to("jpa:" + Contact.class.getName())
     .endRest()
   .get()
    .outType(List.class)
    .produces(MEDIA_TYPE_APP_JSON)
    .route()
      .routeId("list-contact-route")
      .log("listing contacts")
      .to("jpa:" + Contact.class.getName()+"?query={{query}}")
    .endRest();
}
}

Listing 4-2ContactListRoute.java File

这条路线与前一章中的路线基本相同,但是做了一些小的改动,使用关系数据库来“持久化”数据。我在引号中使用 persist 这个词,因为我不是将数据保存在文件中,而是将该文件保存在持久存储单元中。对于这个例子,我使用 H2 作为嵌入式内存数据库。这将允许您运行这个示例,而不必在您的机器上配置数据库实例,但是您不需要修改任何东西来使用它。因为这段代码使用 JDBC 和 JPA,所以对关系数据库的访问是标准化的。因此,如果它是 JDBC 兼容的,那么使用什么关系数据库并不重要。

由于 Quarkus 的工作方式,您需要使用 Quarkus 项目提供的扩展(依赖项)。单纯使用数据库社区或数据库提供商提供的 JDBC 驱动程序可能无法达到预期的效果。

从如何保存联系人开始,在post()操作中,唯一改变的是to("jpa:" + Contact.class.getName())调用。不是调用 bean 将 POJO 保存在内存集合中,而是调用由camel-quarkus-jpa扩展提供的组件将 POJO 保存在数据库中。这个组件生成器只需要实体 bean 类名,以确保正确的主体类型。您已经发送了正确的类型,因为您正在使用 JSON 绑定将传入的 JSON 转换成您需要的实体 bean,即Contact类。

看看现在Contact类是怎么定义的。参见清单 4-3 。

@Entity
public class Contact {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    public Integer id;

    @Column(unique = true)
    public String name;
    public String email;
    public String phone;

    public Contact() { }

}

Listing 4-3Contact.java File

你可以看到这个类的一些简化。不再有重载的构造函数,也没有hash()equals()的实现。这个类现在表示关系数据库中的一个表,因此做了一些改变来反映关系表的特征。例如,一个表需要一个主键,所以我添加了一个顺序数字属性,当你在那个表中插入一个新行并把它命名为“id”时,这个属性会自动生成。“id”属性用@Id@GeneratedValue进行了注释,表示这是主键,因此每个没有 id 值的新条目都将获得一个由数据库中的 identity 列生成的 id 值。

现在查看清单 4-4 中的application.properties文件,并检查您是如何定义对数据库的访问的。

# datasource configuration
quarkus.datasource.db-kind = h2
quarkus.datasource.username = h2
quarkus.datasource.password = h2
quarkus.datasource.jdbc.url = jdbc:h2:mem:db;DB_CLOSE_DELAY=-1

# drop and create the database at startup
quarkus.hibernate-orm.database.generation=drop-and-create

query=select c from Contact c

Listing 4-4contact-list-api-jpa application.properties

Quarkus 中的大多数应用配置都是通过在application.properties文件中设置值来完成的,对于数据源也是如此。您只需要设置参数,Quarkus 就会为您创建连接工厂。我们来分析一下这些参数。

您首先用您将要使用的数据库设置quarkus.datasource.db-kind,在您的例子中是H2

目前,除了H2,Quarkus 还提供以下选项:

  • Apache 德比

  • IBM DB2

  • 马里亚 DB

  • 搜寻配置不当的

  • 关系型数据库

  • 甲骨文数据库

  • 一种数据库系统

用户名和密码是通用值。因为您使用的是嵌入式模式,所以您是在应用启动期间使用提供的信息创建数据库的,所以可以使用任何值。quarkus.datasource.jdbc.url有更多关于应该创建什么的信息。通过设置mem:db,你对H2说你想要创建一个名为db的内存数据库。只要 JVM 存在,您就希望该数据库在 JVM 中可用,这就是为什么DB_CLOSE_DELAY是负数的原因。剩下的配置与模式创建和用于搜索的 JPQL 查询相关。JPA 组件利用 Hibernate 作为 JPA 实现,您可以根据需要使用 Quarkus Hibernate 配置属性来设置 Hibernate。在这个例子中,您告诉 Hibernate,您希望在每次启动应用时重新创建数据库模式。这很有意义,因为每次应用启动时都会重新创建数据库本身。

搜索逻辑或 GET 操作也没有太大变化,但是现在您调用 JPA 组件来执行 JPQL 查询。通话中有新东西to("jpa:"+Contact.class.getName()+"?query={{query}}")。大括号之间的值取自属性文件。通过使用双括号,您可以访问属性文件中的值,并更改在路由中声明元素的方式。在这个例子中,您只设置了一个参数,但是您可以做更多的事情。

在未来的主题中,我们将更深入地探讨如何使用属性来编写 Camel 路线。现在,我们继续讨论对关系数据库的访问。

使用查询“select c from Contact c”可以返回 Contact 表中的每个条目,因为您使用的是 ORM,所以结果将是Contact.classList.class。对于 HTTP 响应,该返回将自动转换为 JSON。

让我们试试这个应用。在您的终端上运行以下命令:

contact-list-api-jpa $ mvn quarkus:dev

您可以从搜索联系人条目开始。您知道那里什么也没有,但是您想检查应用的行为。你可以提出以下要求:

$ curl -v -w '\n' http://localhost:8080/contact

使用开关-v,这样您就知道 HTTP 响应代码是什么。您得到的响应应该类似于图 4-1 。

img/514395_1_En_4_Fig1_HTML.jpg

图 4-1

获取操作响应

如您所见,您从 API 获得了成功的响应。因为您还没有任何条目,所以您得到了一个空列表作为响应。不过,这可能是一种不受欢迎的行为。当您没有条目时,您可能希望用一个204 HTTP 代码来响应,这意味着“no content found.”。在这种情况下,您需要在 JPA 组件响应之后添加验证逻辑,因为它总是会返回一个列表。

现在让我们使用 API 向数据库添加一个条目。在您的终端中运行以下命令:

$ curl -w '\n' -X POST 'http://localhost:8080/contact' \
-H 'Content-Type: application/json' \
--data-raw '{ "phone": "333333333", "name": "Tester Tester", "email": "tester@email.com"}'

您应该会收到类似于清单 4-5 的响应。

{
  "id": 1,
  "name": "Tester Tester",
  "email": "tester@email.com",
  "phone": "333333333"
}

Listing 4-5POST Operation Response

您没有发送 id,但是由于您的条目被保存,因此为其生成了一个新的 id。在这种情况下,JPA 组件的响应是数据库中的持久化对象。

在搜索它们之前,让我们再添加两个条目。在终端中运行以下命令:

$ curl -X POST 'http://localhost:8080/contact' \
-H 'Content-Type: application/json' \
--data-raw '{ "phone": "1111111", "name": "Jane Doe", "email": "jane.d@email.com"}'

$ curl -X POST 'http://localhost:8080/contact' \
-H 'Content-Type: application/json' \
--data-raw '{ "phone": "2222222", "name": "John Doe", "email": "john.d@email.com"}'

现在,您可以使用以下命令搜索联系人:

$ curl http://localhost:8080/contact

如果您只添加了这三个联系人,您的响应应该类似于清单 4-6 。

[{
  "id": 1,
  "name": "Tester Tester",
  "email": "tester@email.com",
  "phone": "333333333"
}, {
  "id": 2,
  "name": "Jane Doe",
  "email": "jane.d@email.com",
  "phone": "1111111"
}, {
  "id": 3,
  "name": "John Doe",
  "email": "john.d@email.com",
  "phone": "2222222"
}]

Listing 4-6GET Operation Response

使用 JPA 的参数化查询

在使用 JPA 组件的第一个示例中,您执行了一个简单的 find all 查询,但是通常您需要对查询进行参数化,以便只检索数据库中的特定条目。现在,您将学习如何使用 JPA 组件进行不同的搜索,以及如何将参数值动态传递给查询。

为了更多地了解 JPA 组件是如何工作的,您需要对contact-list-api-jpa项目做一些修改。让我们用一个新项目来代替在它上面应用更改:这个项目就是contact-list-api-v2

在 IDE 中打开它。先来分析一下RouteBuilder;参见清单 4-7 。

public class ContactListRoute extends RouteBuilder {

    public static final String MEDIA_TYPE_APP_JSON = "application/json";

    @Override
    public void configure() throws Exception {

        declareInterface();
        declareGetListContactRoute();
        declareSearchContactByIdRoute();
        declareSaveContactRoute();

    }

    private void declareInterface(){
        rest("/contact")
        .bindingMode(RestBindingMode.json)
        .post()
            .type(Contact.class)
            .outType(Contact.class)
            .consumes(MEDIA_TYPE_APP_JSON)
            .produces(MEDIA_TYPE_APP_JSON)
            .to("direct:create-contact")
        .get()
            .outType(List.class)
            .produces(MEDIA_TYPE_APP_JSON)
            .to("direct:list-contacts")
        .get("/{id}")
            .outType(Contact.class)
            .produces(MEDIA_TYPE_APP_JSON)
            .to("direct:search-by-id");
    }

...

Listing 4-7ContactListRoute.java Snippet

我决定改变我宣布路线的方式,因为现在路线开始变得有点复杂。我没有声明嵌套在 REST DSL 中的路由逻辑,而是决定将每个路由分开,并使用direct调用它们。这样我可以提高代码的可读性,并且通过小的部分来解释事情会更容易。

declareInterface()方法负责声明 REST API 接口。注意新的 GET 操作,但是这个操作使用了 path 参数。这个想法是,对于这种情况,您将总是检索单个结果,这就是为什么您将outType()改为Contact.class,而不是List.class

先从id的搜索开始。看看清单 4-8 中的路由是如何实现的。

private void declareSearchContactByIdRoute(){
from("direct:search-by-id")
  .routeId("search-by-id-route")
  .log("Searching Contact by id = ${header.id}")
  .setBody(header("id").convertTo(Integer.class))

  .to("jpa:" + Contact.class.getName()+ "?findEntity=true");
}

Listing 4-8declareSearchByContactId Method

这里,您在 JPA 组件声明中使用了一个新的查询参数findEntity。当findEntity设置为真时,组件将试图寻找在组件 URI 路径参数中声明的类的单个实例,在本例中为Contact.class.getName()。该组件将使用消息体作为选择操作的键。这就是你需要header() fluent builder 的原因。由于Contact类的键是一个Integer对象,fluent 构建器从消息头中提取作为 URL 参数传递的值,将其解析为一个Integer对象,并将其设置在消息体中。

让我们试试这部分代码。你不需要发送几个帖子请求就可以搜索到一些东西。看看资源目录中的import.sql文件(清单 4-9 )。

INSERT INTO CONTACT(NAME,EMAIL,PHONE,COMPANY) VALUES
('John Doe','john.d@email.com','1111111','MyCompany');
INSERT INTO CONTACT(NAME,EMAIL,PHONE,COMPANY) VALUES
('Jane Doe','jane.d@email.com','2222222','MyCompany');
INSERT INTO CONTACT(NAME,EMAIL,PHONE,COMPANY) VALUES
('Tester Test','test@email.com','00000000','Another Company');

Listing 4-9import.sql File

这个文件用于在 Hibernate 创建模式后执行 DML 命令。这样,在开始测试之前,您可以运行几个 insert 命令来填充数据库。如果您想添加更多的条目,您仍然可以使用 POST 操作,但是现在您不需要这样做来测试搜索。

启动应用,并在终端中运行以下命令:

contact-list-api-v2 $ mvn quarkus:dev

您可以像这样通过id开始搜索:

$ curl -w '\n' http://localhost:8080/contact/2

结果应该如图 4-2 所示。

img/514395_1_En_4_Fig2_HTML.jpg

图 4-2

按 Id 响应搜索

现在,让我们看看列表搜索路线是什么样子的;参见清单 4-10 。

private void declareGetListContactRoute(){
 from("direct:list-contacts")
 .routeId("list-contact-route")
 .choice()
 .when(simple("${header.company} != null"))
  .log("Listing contacts by company = ${header.company}")
  .to("jpa:" + Contact.class.getName()+ "?query={{query.company}}&parameters=#mapParameters")
 .otherwise()
  .to("jpa:"+Contact.class.getName()+"?query={{query.all}}");
}

Listing 4-10declareGetListContactRoute Method

现在,list contacts route 将能够进行两种不同的搜索,检索所有联系人,或者查找给定公司的联系人。您可能已经注意到,在 insert 语句中,一个新字段被添加到了实体Contact中,即 company。此字段表示联系人列表中联系人的一些共同之处,有助于举例说明使用特定动态参数进行搜索的工作方式。

您必须使用带有选择的条件流,因为两个搜索具有相同的路径和方法。唯一的区别是,当您想要基于公司进行搜索时,您必须在 HTTP 请求中将公司名称作为查询参数进行传递。所以你检查header.company是否为空,如果不是,你基于公司名称进行搜索。否则,您将检索所有条目。如果您查看query.company属性值,您会发现现在您在 JPQL 查询中使用了“where”子句。参见清单 4-11 。

...
query.all=select c from Contact c
query.company=select c from Contact c where c.company = :company

Listing 4-11application.properties File Snippet

该查询使用一个命名参数:company作为where子句的条件。如果您返回到路由并查看 JPA 组件声明,除了查询之外,您还将一个 bean 引用作为参数用“parameters=#mapParameters”传递。此查询参数需要一个对Map<String,Object>对象的引用。看看清单 4-12 所示的类中的最后一个方法。

@Produces()
@Named("mapParameters")
public Map createMapParameters(){
  Map<String, Object> parameters = new HashMap<>();
  parameters.put("company", "${header.company}" );
  return  parameters; }

Listing 4-12createMapParameters Method

您正在使用 CDI 注册一个包含查询所需参数的命名 bean。您使用 JPQL 命名参数作为映射的键,并且可以将任何对象设置为值。在这种情况下,您使用一个Simple表达式来动态地从每个交换的消息头中检索值。由于头返回值将是一个String对象,所以不需要担心对象解析。

我们来试试这些操作。在应用运行时,执行以下命令以返回所有条目:

$ curl http://localhost:8080/contact

要根据公司名称进行搜索,您可以使用以下命令:

$ curl http://localhost:8080/contact?company=Another%20Company

在本例中,将只返回一个结果,如图 4-3 所示,因为您只有一个公司名称等于“Another Company.的条目

img/514395_1_En_4_Fig3_HTML.jpg

图 4-3

按公司名称搜索响应

您还可以使用头以更动态的方式传递参数。清单 4-13 显示路线。

private void declareGetListContactRoute(){
from("direct:list-contacts")
.routeId("list-contact-route")
.choice()
.when(simple("${header.company} != null"))
.log("Listing contacts by company = ${header.company}")
.process(new Processor() {
  @Override
  public void process(Exchange exchange) throws Exception {
    Map<String, Object> parameters = new HashMap<>();
    parameters.put("company", "${header.company}" );
    exchange.getMessage().setHeader(
       JpaConstants.JPA_PARAMETERS_HEADER, parameters);
  }
})
.to("jpa:"+Contact.class.getName()+"?query={{query.company}}")
.otherwise()
.to("jpa:"+Contact.class.getName()+"?query={{query.all}}");
}

Listing 4-13Alternative Way to Pass Parameters

正如您所看到的,在这个例子中,您不需要在组件配置中将 bean 引用作为查询参数传递。使用JPA_PARAMETERS_HEADER头动态发送参数。

特别是对于这个例子,第一种方法更好,因为您没有真正改变参数值,因为您正在使用Simple。在这里,当您需要使用仍然需要动态传递的不同对象类型时,您有了一个参考。

处理

在处理数据库时,一个非常重要的问题是如何使数据保持一致的状态。当然,数据库已经实现了许多保证数据一致性的机制,但是数据库不能控制访问它的应用是否正在执行正确的操作来改变数据库状态。我所说的正确操作是指改变数据库状态,并使其与使用它的系统或应用保持一致。

当您开发路由时,大多数情况下您将连接至少两个不同的端点。在前面的例子中,您对数据库使用了 REST 接口,并使用 Camel 实现了数据库中持久化的 REST 服务。尽管这是一个完全功能性的实现,但这不是一个集成。您只是使用 Camel 来实现一项服务。

让我们将持久性需求放在一个集成场景中。看图 4-4 。

img/514395_1_En_4_Fig4_HTML.jpg

图 4-4

集成期间保持数据

假设你在一家公司工作,该公司的应用需要访问合作伙伴提供的特定系统。不是直接访问系统,而是由架构团队决定,应用应该通过一个集成层来使用该服务,除了其他事情之外,该层应该通过将请求保存在数据库中来审核发送到合作伙伴系统的内容。

在一个粗略且过于简单的表示中,路线可能看起来像清单 4-14 。

from("{{rest.interface.definition}}")
.to("{{database.component.definition}}")
.to("{{partner.system.endpoint}}");

Listing 4-14Model Route

在一个理想的世界里,这每次都行得通,但是这个世界从来都不是我们所期望的那样,不是吗?如果合作伙伴的系统在交易过程中出现故障,会发生什么情况?数据库中可能有他们从未收到的请求条目。这将在您的数据库数据中产生不一致,它不能反映集成中真正发生的事情。

为了解决这一问题和许多其他同步情况,您可以在路由中实现事务。

让我们看看如何用 Camel 解决这个问题。在您的 IDE 中打开contact-list-api-transacted项目。让我们从分析清单 4-15 中的RouteBuilder开始。

public class ContactListRoute extends RouteBuilder {

@Override
public void configure() throws Exception {

rest("/contact")
.bindingMode(RestBindingMode.json)
.post()
  .type(Contact.class)
  .outType(Contact.class)
  .consumes(APP_JSON)
  .produces(APP_JSON)
  .route()
    .routeId("save-contact-route")
    .transacted()
    .log("saving contacts")
    .to("jpa:" + Contact.class.getName())
    .log("Pausing the Transaction")
    .process(new Processor() {
    @Override
    public void process(Exchange exchange) throws Exception {
        Thread.sleep(7000);
    }
    })
    .log("Transaction Finished")
  .endRest()
.get()
  .outType(List.class)
  .produces(APP_JSON)
  .route()
    .routeId("list-contact-route")
  .to("jpa:"+Contact.class.getName()+ "?query={{query.all}}");
}
}

Listing 4-15ContactListRoute.java Snippet

我对这个例子做了一些修改。我简化了搜索,所以现在只有搜索。我还删除了import.sql,因为我们在测试中不需要数据库中的任何条目,但是主要的变化是在保存联系路径上。transacted()调用表明我们希望这些路由交换执行被处理,这意味着路由中的操作只有在交换完成时才会被提交。

事务行为取决于所使用的组件。并不是所有的都支持事务。它们必须能够在失败的情况下执行后期提交和回滚操作,例如,这对于 HTTP 客户端来说是不可能的。在这种情况下,我们使用与事务兼容的 JPA 组件,它将自动加入事务。

为了实现这一点,需要另一种配置。请看清单 4-16 中的pom.xml

  ...
<dependencies>
    <dependency>
      <groupId>org.apache.camel.quarkus</groupId>
      <artifactId>camel-quarkus-rest</artifactId>
    </dependency>
    <dependency>
      <groupId>org.apache.camel.quarkus</groupId>
      <artifactId>camel-quarkus-jackson</artifactId>
    </dependency>
    <dependency>
      <groupId>org.apache.camel.quarkus</groupId>
      <artifactId>camel-quarkus-jpa</artifactId>
    </dependency>
    <dependency>
      <groupId>io.quarkus</groupId>
      <artifactId>quarkus-jdbc-h2</artifactId>
    </dependency>
    <dependency>
      <groupId>org.apache.camel.quarkus</groupId>
      <artifactId>camel-quarkus-jta</artifactId>
    </dependency>
  </dependencies>

Listing 4-16pom.xml File Dependencies Snippet

如果你查看camel-quarkus-jpa的依赖项,你会看到quarkus-hibernate-orm。如前所述,Hibernate 是 Quarkus 选择的 JPA 实现。该扩展使用quarkus-narayana实现 JTA (Jakarta Transaction API)。因此,要使这个事务管理器对 Camel 可用,您需要camel-quarkus-jta扩展。它使 Camel 能够使用 Narayana 事务管理器作为事务策略。

让我们试试这个代码。打开三个终端窗口或标签。其中一个将用于运行应用。第二个用于发送 POST 请求,将实体保存到数据库中,第三个用于将数据持久化到数据库中。

在第一个窗口或选项卡中,运行以下命令:

contact-list-api-transacted $ mvn quarkus:dev

一旦应用启动,您可以像这样发送帖子:

$ curl -X POST 'http://localhost:8080/contact' -H 'Content-Type: application/json' --data-raw '{ "phone": "2222222", "name": "John Doe", "email": "john.d@email.com"}'

JPA 调用完成后,线程会休眠 7 秒钟。这将证明,即使您已经“处理”了更改,数据也没有在数据库中持久化,因为事务没有被提交。

在第三个窗口中,您可以列出如下联系人:

$ curl http://localhost:8080/contact

您将看到,在这 7 秒钟内,window cURL 将只返回一个空列表,但是一旦 7 秒钟过去,您将在应用日志中看到“Transaction Finished”,GET 请求将返回您在 POST 命令中发送的实体。

配置事务时要记住的一件重要事情是如何定义事务边界。我说的界限是指你的事务应该在哪里开始,在哪里结束。这可以通过为事务设置适当的传播行为来实现。

有六种传播策略可供选择。他们是

  • PROPAGATION_REQUIRED:默认选项。如果没有启动事务,则启动事务,或者保持现有的事务。

  • PROPAGATION_REQUIRES_NEW:如果没有启动,则启动一个事务。如果有一个已启动,它会挂起它并启动一个新的。

  • PROPAGATION_MANDATORY:如果没有启动事务,它会启动一个异常。

  • PROPAGATION_SUPPORTS:有事务就加入;否则,它不处理任何事务。

  • PROPAGATION_NOT_SUPPORTED:如果有事务,它将被该边界挂起,该流程部分将在没有事务的情况下工作。

  • PROPAGATION_NEVER:如果有交易,则发起异常。它不需要交易就可以工作。

    在示例中,我们将使用默认选项,因为这是最常见的情况。

第一个示例旨在展示提交过程是如何工作的,但是事务的另一个非常重要的功能是在失败的情况下回滚操作。我们来测试一下。在contact-list-api-transacted项目的ContactListRoute.java中执行清单 4-17 中的更改,这里不使用线程休眠,而是抛出一个异常。

...
.process(new Processor() {
    @Override
    public void process(Exchange exchange) throws Exception {
        throw new Exception("Testing Rollback Exception.")
    }
})
...

Listing 4-17ContactListRoute.java Changes

如果已经停止,请再次运行该应用。尝试发出另一个发布请求。您将收到一个错误,一个包含栈跟踪的 HTML 响应页面。如果您尝试列出所有联系人,您将收到一个空列表作为响应。

该事务防止您将数据库置于不一致的状态。手术还没结束,所以你不可能在数据库里登记。

现在移除transacted()调用。您可以这样注释该行://.transacted()

做同样的测试。您仍然会收到一条错误消息作为响应,但是现在您的数据库中有了一个不应该存在的条目。

为了正确处理事务,您还需要指定处理故障的方法。到目前为止,我一直以乐观的方式编码,不考虑某些操作在执行过程中可能会失败。很明显,这里的目的是让代码简单易懂,但我也想特别谈谈 Camel 提供异常处理的不同方式。接下来我们将深入探讨这个问题。

处理异常

预料到可能发生的异常并为它们做好准备是一个非常重要的编程实践,当我们谈论集成时更是如此。我们不能真正相信另一边的东西,即使是我们自己编写的应用。网络并不总是可靠的,应用可能会崩溃,硬件可能会出现故障,因此我们需要让应用准备好处理异常,以避免丢失数据或使系统处于不一致的状态。接下来,您将看到用 Camel.s 处理异常的不同方法

尝试-捕捉-最终

类似于我们使用 Java 处理异常时所做的,Camel 也有可以在路由声明中使用的try/catch/finally子句。让我们看一些如何做的例子。

首先在 IDE 中打开camel-try-catch项目。让我们分析清单 4-18 中显示的TryCatchRoute.java文件。

public void configure() throws Exception {
rest("/tryCatch")
.bindingMode(RestBindingMode.json)
.post()
.type(Fruit.class)
.outType(String.class)
.consumes(APP_JSON)
.produces(TEXT_PLAIN)
.route()
 .routeId("taste-fruit-route")
 .doTry()
   .choice()
   .when(simple("${body.name} == 'apple' "))
     .throwException(new Exception("I don't like this fruit"))
   .otherwise()
     .setBody(constant("I like this fruit!"))
 .endDoTry()
 .doCatch(Exception.class)
   .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(500))
   .setBody(exceptionMessage())
 .doFinally()
   .setHeader(Exchange.CONTENT_TYPE,constant(TEXT_PLAIN))
   .log("Exchange processing completed!")
.endRest();
}

Listing 4-18TryCatchRoute.java Snippet

这条路线有一个简单的逻辑来演示try/catch/finally如何与 Camel 一起工作。这个 REST API 有一个单独的操作,它接收一个作为 JSON 的Fruit.class对象,并验证水果名称是否为“apple”。如果是“apple,抛出异常说“I don't like this fruit”;否则,它会用一个简单的短语“I like this fruit!”来响应。所以我把这条路线叫做taste-fruit-route。顺便说一下,我确实喜欢苹果:这只是一个例子。

这里的一个新东西是你如何使用Simple语言来执行一个方法。通过设置“${ body.name }”你知道体内的对象有一个方法叫name,返回的是一个你可以和字符串apple比较的对象。让我们看看清单 4-19 中的Fruit.java文件。

@Entity
public class Fruit {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Integer id;

  @Column(unique = true)
  private String name;

  public String getName() {

      return name;
  }

  public void setName(String name) {
      this.name = name;
  }

  public Integer getId() {
      return id;
  }

  public void setId(Integer id) {
      this.id = id;
  }
}

Listing 4-19Fruit.java File

在前面的例子中,我为类属性使用了公共访问修饰符,以使代码更小,但是在这个例子中,我需要一个方法来使Simple表达式工作,这就是我选择使用 getters 和 setters 的原因。

为了使用Simple进行方法调用,您需要将camel-quarkus-bean扩展添加到项目中。

一旦满足条件,就使用 DSL throwException()抛出一个异常。这个异常将被您的doCatch()子句捕获,该子句将通过设置正确的响应头和适当的消息给客户端来处理这个异常。如果抛出异常,doFinally()子句将被独立执行,因此您可以使用它为响应设置正确的Content-Type,并记录交换处理已完成。

exceptionMessage()调用是一个值构建器,它封装了一个像这样的Simple表达式:"${exception.message}"

我们来测试一下这条路线。打开终端并运行应用:

camel-try-catch$ mvn quarkus:dev

您可以发送这样的请求:

$ curl -w '\n' -X POST http://localhost:8080/tryCatch \
-H 'Content-Type: application/json' -d '{"name":"grape"}'

由于这不是苹果,所以你不会落入例外,如图 4-5 。

img/514395_1_En_4_Fig5_HTML.jpg

图 4-5

TryCatch 路由响应

尝试一个将导致引发异常的请求:

$ curl -w '\n' -X POST http://localhost:8080/tryCatch \
-H 'Content-Type: application/json' -d '{"name":"apple"}'

你会得到如图 4-6 的答案。

img/514395_1_En_4_Fig6_HTML.jpg

图 4-6

会引发异常。

如您所见,try/catch/finally的工作方式与 Java 语言非常相似。您可以有多个doCatch()子句,您可以将一个try/catch/finally嵌套在一个doTry()中,等等。

让我们看看带有try/catch的事务处理路由是什么样子的。在与之前相同的项目中,打开TryCatchTransactedRoute.java文件,如清单 4-20 所示。

public class TryCatchTransactedRoute extends RouteBuilder {

@Override
public void configure() throws Exception {
rest("/tryCatchTransacted")
  .bindingMode(RestBindingMode.json)
  .post()
    .type(Fruit.class)
    .outType(String.class)
    .consumes(APP_JSON)
    .produces(TEXT_PLAIN)
    .route()
      .transacted()
      .routeId("save-fruit-route")
     .doTry()
     .to("jpa:" + Fruit.class.getName())
     .choice()
     .when(simple("${body.name} == 'apple' "))
     .throwException(new Exception("I don't like this fruit"))
        .otherwise()
        .setHeader(Exchange.CONTENT_TYPE,constant(TEXT_PLAIN))
        .setBody(constant("I like this fruit!"))
      .endDoTry()
      .doCatch(Exception.class)
        .setHeader(Exchange.HTTP_RESPONSE_CODE,constant(500))
        .setHeader(Exchange.CONTENT_TYPE,constant(TEXT_PLAIN))
        .setBody(exceptionMessage())
        .markRollbackOnly()
    .endRest()
  .get()
    .outType(List.class)
    .produces(APP_JSON)
    .route()
     .routeId("list-fruits")
     .to("jpa:"+Fruit.class.getName()+"?query={{query.all}}");
  }
}

Listing 4-20TryCatchTransactedRoute.java

这条路线与前一条不同。它不仅会分析水果名称,还会在数据库中保存一个水果。水果名称必须有唯一的值,正如您在Fruit类声明中看到的,如果您试图用重复的名称保存水果,将会抛出一个违反约束的异常。这个路径还有一个 GET 操作,允许您从数据库中检索一个水果列表。

你可能已经注意到的一件事是没有doFinally()子句。这是因为您必须处理事务回滚的方式。如果你分析doCatch()块,最后一步是markRollbackOnly()。这意味着您将在此时回滚事务,但不会抛出异常,这意味着异常得到了正确处理。在这之后,什么都不会执行,这就是为什么要在调用之前通过设置消息头和消息体来准备响应。这也是你在这里没有使用doFinally()的原因。只有在没有异常发生的情况下,它才会被执行。

测试这条路线。在应用运行的情况下,运行以下命令两次:

$ curl -X POST http://localhost:8080/tryCatchTransacted \
-H 'Content-Type: application/json' -d '{"name":"pineapple"}' -w '\n' -v

在您第二次请求时,您将收到类似于图 4-7 的内容。

img/514395_1_En_4_Fig7_HTML.jpg

图 4-7

TryCatchTransacted 路由响应

内容类型和 HTTP 状态代码正是您在doCatch()块中设置的,响应主体是异常消息。

现在试着发送“apple”作为请求:

$ curl -X POST http://localhost:8080/tryCatchTransacted \
-H 'Content-Type: application/json' -d '{"name":"apple"}'

然后检查数据库中是否有“apple”:

$ curl http://localhost:8080/tryCatchTransacted

使用 transactioned plustry/catch允许您捕获异常,向客户端提供经过处理的响应,并回滚可能导致数据库不一致的操作,但这不是唯一的方法。让我们学习一个新的。

错误处理程序

使用try/catch/finally是处理局部异常的一种简单方法,在这种情况下,您可以捕获路径内给定块的异常。我决定从它们开始,因为它们类似于我们在 Java 语言中所拥有的,但是除了路由中的映射块之外,还有其他方法来处理异常。让我们看看他们。

Camel 附带了处理异常的预定义策略,称为错误处理程序。有四种错误处理程序,它们分为两类,事务处理和非事务处理。

未交易的有

  • DefaultErrorHandler:默认错误处理程序。它会将异常传播回调用者。

  • DeadLetterChannel:它允许消息在发送到死信端点之前重新传递。

  • 当你想使用任何一个提供的错误处理程序时,使用它。

对于事务处理的路由,我们有TransactionErrorHandler,这是这种路由的默认错误处理程序。

尽管我以前没有提到过错误处理程序,但我们一直在使用它们。如果以您正在测试的事务处理路由为例,如果您在测试期间查看日志,您将会看到如下日志条目:

[org.apa.cam.jta.TransactionErrorHandler] (vert.x-worker-thread-3) Transaction rollback (0x2627d493) redelivered(false) for (MessageId: 0C577FF9615449E-0000000000000001 on ExchangeId: 0C577FF9615449E-0000000000000001) due exchange was marked for rollbackOnly

这是一个关于TransactionErrorHandler如何工作的例子。您不必配置它。因为您的路由是事务性的,如果没有指定一个TransactionErrorHandler,将会创建一个新的路由并分配给该路由。

不再拖延,让我们看看如何配置错误处理程序。

首先在 IDE 中打开camel-dead-letter项目。这个项目有三个不同的RouteBuilders,每个都有不同的目的。让我们从负责声明您将与之交互的接口的RouteBuilder开始。打开清单 4-21 所示的RestRoute.java文件。

public class RestRoute extends RouteBuilder {

  @Override
  public void configure() throws Exception {

    rest("/deadLetter")
    .consumes(TEXT_PLAIN)
    .produces(TEXT_PLAIN)
    .post()
    .route()
     .routeId("rest-route")
     .log("Redirecting message")
     .wireTap("seda:process-route")
     .setBody(constant("Thanks!"))
    .endRest();

  }
}

Listing 4-21RestRoute.java File

在本例中,您公开了一个 REST 接口,该接口接受带有text/plain主体的 POST 请求。然后,接收到的消息被窃听到使用 SEDA 组件的另一个路由,之后,在向客户端返回响应之前,消息体被更改。

首先,我们先明确一下wiretap()是什么。Wire Tap 是一种集成模式,它允许使用来自原始交换的数据复制交换或生成新的交换,并将其异步发送到另一个端点。这里的消息模式是inOnly,因为主路由不会等待端点响应。

另一个对路线开发很有价值的新事物是 SEDA。该组件基于分阶段的事件驱动架构,将创建一个内存队列,以便您可以在不同的线程中处理消息。通过这种方式,您可以异步发送消息副本,由另一个线程进行处理。

看看清单 4-22 中显示的声明 SEDA 消费者的RouteBuilder

public class ProcessRoute extends RouteBuilder {

@Override
public void configure() throws Exception {

    errorHandler(deadLetterChannel("seda:dead-letter")
    .maximumRedeliveries(1)
    .useOriginalMessage()
    .onExceptionOccurred(new Processor() {
      @Override
      public void process(Exchange exchange) throws Exception
      {
        log.error("Exception Message : " +
           exchange.getException().getMessage()) ;
        log.error("Current body:\"" +
             exchange.getMessage().getBody() +"\"");
      }
    }));

    from("seda:process-route")
    .routeId("process-route")
    .bean("exceptionBean")
    .log("Message Processed with body : ${body}");

    }
}

Listing 4-22ProcessRoute.java

这条路线背后的想法是向您展示使用错误处理程序的不同可能性。这里您声明了一个deadLetterChannel错误处理程序,并设置了一些策略来定义这个错误处理程序应该如何工作。

有两种方法可以声明错误处理程序,即使用路径生成器范围或使用路径范围。在本例中,您使用的是路由生成器作用域,因此如果您在此路由生成器中添加另一个路由器,此新路由也将使用已定义的配置(除非它是事务处理路由)。由于这个错误处理程序的例子影响了一个单一的路由,您可以像清单 4-23 那样声明它。

@Override
public void configure() throws Exception {

from("seda:process-route")
.routeId("process-route")
.errorHandler(deadLetterChannel("seda:dead-letter")
  .maximumRedeliveries(1)
  .useOriginalMessage()
  .onExceptionOccurred(new Processor() {
  @Override
  public void process(Exchange exchange) throws Exception {
    log.error("Exception Message : " +
       exchange.getException().getMessage());
    log.error("Current body: \"" +
      exchange.getMessage().getBody()+"\"");
  }
  }))
.bean("exceptionBean")
.log("Message Processed with body : ${body}");

 }
}

Listing 4-23ProcessRoute with Route Scope

观察错误处理程序配置。第一个参数是设置在所有尝试之后消息应该发送到哪里。在这里,您将发送原始消息,即发送到 SEDA 队列的消息,在对“seda:dead-letter”重试一次后,当重试期间出现异常时,将调用一个处理器来记录有关交换的一些信息。

让我们看看死信路线是什么样子的。打开清单 4-24 所示的DeadLetterRoute.java文件。

public class DeadLetterRoute extends RouteBuilder {

    @Override
    public void configure() throws Exception {

        from("seda:dead-letter")
        .routeId("dlq-route")
        .log("Problem with request \"${body}\"");

    }

}

Listing 4-24DeadLetterRoute.java File

这个路由唯一做的事情就是记录它收到的消息体。这将帮助我演示不同的配置如何影响路由逻辑。

关于流程路线,最后要评论的是被调用的 bean。该 bean 将根据处理尝试的次数生成异常。让我们检查一下。打开清单 4-25 所示的ExceptionBean.java文件。

@Singleton
@Named("exceptionBean")
@Unremovable
public class ExceptionBean {

    private static final Logger LOG = Logger.getLogger(ExceptionBean.class);

    int counter;

    public void analyze(Message message) throws Exception{

        ++counter;

        LOG.info("Attempt Number " + counter);

        message.setBody(UUID.randomUUID().toString());

        if(counter < 3){
            throw new Exception("Not Now!");
        }
    }
}

Listing 4-25ExceptionBean.java File

这个 singleton 有一个int计数器来记录路由尝试的次数。它通过设置一个随机 UUID 来改变主体,如果尝试的次数少于三次,它将抛出一个异常,这将激活错误处理程序。

所以我们来试试吧。打开终端,运行以下命令启动应用:

camel-dead-letter $ mvn quarkus:dev

一旦应用启动并运行,您可以像这样向 REST API 发送一个请求:

$ curl -X POST  http://localhost:8080/deadLetter -H 'Content-Type: text/plain' -d "testing dead letter"

查看应用日志。它将像清单 4-26 一样。

[rest-route] [INFO] Redirecting message
[co.ap.in.ExceptionBean] [INFO] Attempt Number 1
[co.ap.in.ro.ProcessRoute] [ERROR] Exception Message : Not Now!
[co.ap.in.ro.ProcessRoute] [ERROR] Current body: "7b3cc16c-d409-4166-b12d-b23299729999"
[co.ap.in.ExceptionBean] [INFO] Attempt Number 2
[co.ap.in.ro.ProcessRoute] [ERROR] Exception Message : Not Now!
[co.ap.in.ro.ProcessRoute] [ERROR] Current body: "2520a0db-a75c-4324-9f0c-722aa073c60d"
[dlq-route] [INFO] Problem with request "testing dead letter"

Listing 4-26Application Logs Snippet

正如您在日志中看到的,有两次尝试让 bean 调用工作。在每一次尝试中,在抛出异常之前都修改了消息体,但是当错误处理程序达到重试次数的限制时,“dlq-route”就会收到原始消息并打印出来。

您可以进行第二次请求,现在不会抛出任何异常。您将会看到如下的日志条目:

[process-route] [INFO] Message Processed with body : cb65c846-993a-4835-9e90-f077961f90b2

这意味着process-route能够进行到最后一步。

您还可以探索其他策略和参数,比如在每次重试之间添加一个延迟,使用修改后的消息并将其发送到死信地址,或者甚至根据表达式语言定义重试的截止时间。设置错误处理程序时有很多可能性。在考虑如何处理异常时,请记住这一点:

  • 我故意放了一个异步inOnly例子。我对死信配置所做的不适合需要向客户端提供同步答案的消费者。在本例中,我们正在处理可以在以后处理的消息,拥有一个可以保存消息的死信通道有助于在将来重放它们,或者只是进行故障排除。

  • 在这个例子中,我使用一个 bean 强制一个异常,但是它可以是任何可以抛出异常的组件。在这种情况下,您可以使用 redelivery 来重试对组件的调用。当我们访问一个可能暂时没有响应的外部端点时,这一点特别有趣。

让我们继续学习处理异常的新方法。

一个例外条款

您看到了如何使用try/catch/finally处理异常来包围 route 块。在错误处理程序示例中,演示了如何以更通用的方式处理异常,在路线生成器级别或路线级别定义错误处理程序。使用这种新方法,您仍然可以用这种更广泛的方式来分配处理程序,并根据异常类型来专门化它们。让我们来学习一下onException()子句。

在您的 IDE 中,打开camel-on-exception项目。这个项目有三个路径构建器,演示了使用onException()的不同方法。让我们从一条non-transacted类型的路线开始,因此打开清单 4-27 中所示的OnExceptionRoute.java文件。

public class OnExceptionRoute extends RouteBuilder {

@Override
public void configure() throws Exception {

onException(Exception.class)
.handled(true)
.log(LoggingLevel.ERROR, "Exception Handled")
.setHeader(Exchange.HTTP_RESPONSE_CODE, constant(500))
.setBody(exceptionMessage());

onException(DontLikeException.class)
.handled(true)
.log(LoggingLevel.ERROR,"DontLikeException Handled")
.setHeader(Exchange.HTTP_RESPONSE_CODE, constant(500))
.setBody(constant("There is a problem with apples, try another fruit."));

rest("/onException")
  .bindingMode(RestBindingMode.json)
  .post()
    .type(Fruit.class)
    .outType(String.class)
    .consumes(APP_JSON)
    .produces(TEXT_PLAIN)
    .route()
      .routeId("taste-fruit-route")
      .choice()
      .when(simple("${body.name} == 'apple' "))
        .throwException(new DontLikeException())
      .otherwise()
        .throwException(new Exception("Another Problem."))
      .end()
      .log("never get executed.")
     .endRest();
 }}

Listing 4-27OnExceptionRoute.java File

您又回到了水果的例子,但是这一次无论您选择哪个水果,都会抛出一个异常(但是如果您在请求中发送"apple",它会有不同的响应消息。).

这里的想法是向您展示您可以在相同的路径中处理不同种类的异常,也向您展示您可以有不同的onException()声明范围。

这里我使用了 route builder 范围,因为只有一条路由,这使得路由可读性更好。

两个声明的异常是handle(),这意味着这些异常不会传播回调用者,在本例中是 HTTP 客户端。通过说异常已被处理,如果路由是inOut,执行将在抛出异常的地方停止,并且在将响应发送回客户端之前执行onException()块。这就是为什么行log("never get executed.")永远不会被执行。

试试这段代码。从终端启动应用:

camel-on-exception $ mvn quarkus:dev

一旦应用启动,发送如下请求:

$ curl -w '\n' -X POST http://localhost:8080/onException \
-H 'Content-Type: application/json' -d '{"name": "apple"}'

您将得到如图 4-8 所示的响应。

img/514395_1_En_4_Fig8_HTML.jpg

图 4-8

一个例外路由响应

当谈到事务路由时,使用onException()并没有什么不同。你只需要记住你需要标记回滚的路径。请看清单 4-28 中的OnExceptionTransactedRoute.java文件示例。

public class OnExceptionTransactedRoute extends RouteBuilder {

@Override
public void configure() throws Exception {
rest("/onExceptionTransacted")
  .bindingMode(RestBindingMode.json)
  .post()
    .type(Fruit.class)
    .outType(String.class)
    .consumes(APP_JSON)
    .produces(TEXT_PLAIN)
    .route()
      .routeId("save-fruit-route")
      .onException(Exception.class)
       .handled(true)
       .setHeader(Exchange.HTTP_RESPONSE_CODE, constant(500))
       .setHeader(Exchange.CONTENT_TYPE, constant(TEXT_PLAIN))
       .setBody(exceptionMessage())
       .markRollbackOnly()
      .end()
      .transacted()
      .to("jpa:" + Fruit.class.getName())
     .choice()
     .when(simple("${body.name} == 'apple' "))
     .throwException(new Exception("I don't like this fruit"))
     .otherwise()
        .setHeader(Exchange.CONTENT_TYPE,constant(TEXT_PLAIN))
        .setBody(constant("I like this fruit!"))
    .endRest()
  .get()
   .outType(List.class)
   .produces(APP_JSON)
   .route()
    .routeId("list-fruits")
    .to("jpa:"+Fruit.class.getName()+"?query={{query.all}}" );

    }
}

Listing 4-28OnExceptionTransactedRoute.java File

在本例中,您在一个路由范围中使用了onException(),并且和前面的例子一样,将异常标记为handled(true)。在onException()块中,通过设置消息头和消息体来准备对客户端的响应。一旦完成所需的设置,就可以将交换设置为回滚。

这条路线就像try/catch的例子一样。它将回滚任何带有“apple”的条目,以及任何具有先前保存的名称的条目。它还有一个 GET 操作,因此您可以检查数据库中的值。

在应用仍在运行的情况下,发送一个将被 API 接受的请求:

$ curl -X POST http://localhost:8080/onExceptionTransacted \
-H 'Content-Type: application/json' -d '{"name": "grape"}'

重复相同的请求,然后尝试发送带有“apple”的请求:

$ curl -X POST http://localhost:8080/onExceptionTransacted \
-H 'Content-Type: application/json' -d '{"name": "apple"}'

现在,您可以查看数据库中有多少水果:

$ curl http://localhost:8080/onExceptionTransacted

只有第一个非 apple 请求会被持久化,因此您将收到一个列表,其中只有一个项目,如下所示:

[{"id":1,"name":"grape"}]

还有一个例子可以探究如何用 Camel 处理异常。这一次,您将处理异常并继续原来的路由,因为异常没有发生。

在 IDE 中打开清单 4-29 中的OnExceptionContinuedRoute.java文件。

public class OnExceptionContinuedRoute extends RouteBuilder {

@Override
public void configure() throws Exception {

onException(Exception.class)
.continued(true)
.log(LoggingLevel.ERROR, "Exception Handled")
.setHeader(Exchange.HTTP_RESPONSE_CODE, constant(500))
.setBody(exceptionMessage());

rest("/onExceptionContinued")
  .bindingMode(RestBindingMode.json)
  .post()
  .type(Fruit.class)
  .outType(String.class)
  .consumes(APP_JSON)
  .produces(TEXT_PLAIN)
  .route()
    .routeId("continued-route")
    .choice()
    .when(simple("${body.name} == 'apple' "))
  .throwException(new DontLikeException("Try another Fruit."))
    .otherwise()
      .setBody(constant("I like this fruit."))
    .end()
    .setHeader(Exchange.CONTENT_TYPE, constant(TEXT_PLAIN))
    .log("Gets Executed.")
  .endRest();

    }
}

Listing 4-29OnExceptionContinuedRoute.java

这段代码是OnExceptionRoute的简化版本,因为我只需要一个onException()子句来展示continued()是如何工作的。正如你所看到的,你没有使用handled(),而是使用了continued(),这意味着异常将被捕获,然后onException()块将被执行,然后你将返回到抛出异常的原始路径。这就是为什么要在选择之后设置内容类型头。有没有得到异常并不重要;这最后一段代码将被执行。

随着应用的运行,尝试在请求中发送一个"apple":

curl -X POST http://localhost:8080/onExceptionContinued \
-H 'Content-Type: application/json' -d '{"name": "apple"}'

您将像前面的示例一样收到经过处理的响应,但是请查看应用日志。您会发现日志条目是路由的最后一部分,向您显示原始路由继续。日志条目将如下所示:

2021-05-31 08:56:55,224 INFO  [continued-route] (vert.x-worker-thread-1) Gets Executed.

您探索了在 Camel 中执行错误处理的三种不同方式。当然,在每种机制中可以应用更多的配置,但是在这里,您探索了错误处理的主要方面和案例。一旦理解了它们中的每一个,您将能够为您的给定用例选择最好的一个。

摘要

在本章中,您关注了如何持久化和消费来自关系数据库的数据,以及在操作数据时保持数据一致性的技术。随着您的进步,您看到了使用 Camel 执行错误处理的不同方式。

在本章中,您学习了以下主题:

  • 如何使用 quartus 配置数据源和 hibernate

  • 如何使用camel-quarkus-jpa来保存和消费关系数据库中的数据

  • 如何使用 quartus 和 camel 配置 jta 事务

  • 事务处理和非事务处理路由的不同错误处理策略

在下一章中,我们将关注使用消息传递的异步通信以及这种方法带来的架构可能性。

五、使用 Apache Kafka 发送消息

在前面的章节中,我们主要关注跨应用通信的同步方法的使用,更具体地说是 REST。尽管 REST(及其生态系统)的特性非常丰富,可以支持许多不同的用例,但它不像某些异步通信模式那样具有可伸缩性和弹性。同步通信要求后端能够立即响应客户端的调用,但是一些服务由于其复杂性可能需要更多的时间来响应,或者甚至可能依赖于其他服务,这可能会产生超时链,其中链中的一个服务可能会成为瓶颈并使请求开始失败。

在有状态与无状态的讨论中,由于无状态架构的许多好处,我们理想地尝试无状态,我们知道不是每个应用都是无状态的。对于一些必须同步执行的流程和服务也是如此。这个想法是为了理解异步通信是如何工作的,我们拥有哪些模式,以及我们如何实现它们。我们可以将反应式编程或事件驱动编程作为异步处理如何有益于应用性能的例子。这意味着没有一个组件需要等待另一个组件来完成它的任务,所以它可以不等待,而是执行别的事情。

在这一章中,我们将深入研究使用消息传递系统的异步通信。选择的工具是另一个 Apache 基金会项目,Apache Kafka。您将学习何时以及如何使用消息传递来使用异步通信,同时学习如何使用 Kafka 和 Camel 来实现它。

面向消息的中间件

面向消息的中间件(MOMs)是专门接收和发送消息格式的数据的系统。他们充当中间人的角色,保证从生产者那里接收到消息,并且该消息将是可用的,并被正确地传递给消费者。让我们来讨论一下这种系统是如何工作的,我们能从中得到什么。

在谈论妈妈之前,我们首先需要定义消息传递。谈论消息传递可能听起来有些多余,因为这个概念在我们的生活中非常普遍。电子邮件、短信应用,甚至邮政服务都可以作为消息传递的一个很好的类比,即使用服务在实体之间传递消息。消息传递的另一个特征是,生产者或消息发送者不需要等待消费者或消息接收者来确认它收到了消息。消息生产和消息消费的中介由中间件层完成。正是中间件层向生产者确认消息的传递,一旦有了它,该层就有了保证消息到达目的地的机制。这使得生产者无需等待消费者就可以执行其他任务,而消费者可以按照自己的节奏使用消息。

除了异步通信的特性之外,由于其模式,mom 的使用还允许其他可能性。我们可以强调的一点是使用 mom 如何提高服务的可组合性。

可组合性是一种设计原则,它分析服务如何相互连接,以创建可以服务于新用例的组合。为了阐明这意味着什么,让我们把这个概念放到一个真实的生活例子中。想想社交媒体。这一切都是从人们联系的方式开始的。你想知道你的家人怎么样,你的朋友在做什么,甚至想认识新的人。现在将它与现在的情况进行比较。它现在是一个公众人物的平台,从政治家到流行艺术家,在这里你可以买卖任何东西,观看现场音乐表演或体育比赛等活动。它在服务和容量方面增长如此之快,以至于我们觉得我们生活的方方面面都在那里得到了处理。这是可能的,因为人们的数据处理方式。当然,这是一个非常有说服力的例子,因为它带来的不仅仅是可组合性的概念,而是为了举例说明它的含义。

妈妈们通常使用目的地的概念,也就是消息被发送的地址。这个目的地并不存在于生产者或消费者中,而是消息传递系统或消息代理中的一个寄存器,这使得它成为一种通过促进解耦来提高可组合性的方法。生产者不知道谁在消费来自目的地的消息,消费者也不知道谁在发送消息。这样,如果生产者能够产生预期的数据,我们可以向目的地添加更多的生产者,或者如果数据与更多的应用相关,我们可以添加更多的消费者。注意,这种通信更多的是关于被交换的数据,而不是组件的关系。一个好的领域设计对于实现更好的可组合性是必不可少的。

目的地通常分为两种类型:队列和主题。我们单独讨论一下。

队列是通信通道的目的地,接收方通常是单个应用。它们就像电子邮件收件箱。他们可以接收来自任何人的消息,但只有电子邮件所有者能够访问这些信息。消息会一直留在队列中,直到被相应的接收者使用,或者直到“生存时间”等规则(根据消息在队列中的时间来清除消息)生效。你可以在图 5-1 中看到一个队列的表示。

img/514395_1_En_5_Fig1_HTML.jpg

图 5-1

队列表示

另一方面,话题是传播信息的交流渠道。不同的用户可以订阅一个主题,一旦任何消息可用,所有用户(如果没有配置过滤)都会收到该消息的副本。您可以在图 5-2 中看到一个主题表示。

img/514395_1_En_5_Fig2_HTML.jpg

图 5-2

主题表征

这是消息传递中主要概念的总体表示。根据您使用的产品或语言,这些概念可能有不同的名称。这里我使用 Jakarta 消息传递(以前的 JMS,Java 消息传递)规范术语。规范本身在 Java 语言使用的公共 API 中抽象了这些概念,独立于供应商或实现,因此当我们使用 Java 创建与消息传递的集成时,使用这种命名法是公平的。

谈到实现,我们还必须考虑队列和主题的其他方面,而持久性可能是主要方面。

队列和主题可以是持久的,也可以不是。这完全取决于生产者和消费者的关系以及数据的性质。以负责监控卡车位置的设备为例。它会不时发送通知消息,告知卡车的当前坐标。根据卡车的移动方式,如果它在交通中停止或正在加油,您可能会收到许多具有相同坐标的消息。对于这种情况,并不是每条消息都很重要。真正重要的是消息排序。它可能会丢失一些消息,但仍然不会影响整体监控,但消息需要不断出现,消费者需要跟上生产者的节奏。在这种情况下,我们可以轻松地实现非持久队列。我们将获得中间件层的灵活性,并允许生产者向消费者提供更好的吞吐量。我们仍然需要注意中间件资源,以允许它接收生产者的负载,并在消费者比生产者慢时暂时保持它。

在这种情况下,排序也很重要。代理之间的消息负载均衡之类的策略将变得更加难以实现,因为这可能导致生产者向不同的代理发送消息,而消费者不按照发送顺序访问代理。不过,如果我们有不同的案子,这就不是问题了。

想象一下,我们有一个电子商务网站,将客户订单放在队列中进行处理。在这种情况下,排序不是问题,因为每个订单都是完全独立的事件。因此,我们可以通过负载均衡轻松地分配负载,但每个事件都很重要,不容错过。对于这种情况,我们需要一个持久的队列来保证我们有机制在代理失败时恢复消息,因为我们不能冒丢失消息的风险。

题目也有类似的要求。更传统的实现使主题成为订阅者的广播机制,订阅者在代理收到消息时可用,因此消息并没有真正持久化,但仍然存在我们需要广播消息并保证消费者稍后可以获得它们的情况。传统的消息传递是关于消费消息的。一旦消息被处理,它就会从目的地被删除。对于主题来说,完全采用这种机制会更加困难,因为很难期望所有的订阅者都阅读特定的条目,或者在任何给定的时间添加更多的订阅者,并且仍然保持一致。为了解决主题需要持久性的问题,不同的产品实现了不同的机制,从将消息从主题路由到专门为订阅者创建的持久性队列,到不基于读取而是基于时间或存储空间来删除消息,您将在后面看到。

高可用性、性能、数据复制、可伸缩性、监控和其他因素将引导您实现一个特定的消息代理。我们不会孤立地讨论这些特征;我们在描述 ApacheKafka 的时候会谈到他们。

ApacheKafka

因此,让我们通过在一个实现产品中具体化消息代理理论来实现它。您将学习 Kafka 的核心概念和特征,然后学习如何在 Camel 中使用它们。

用项目网页的话说,“Apache Kafka 是一个开源的分布式事件流平台”。让我们分解其中的一些单词来理解它们真正的意思。从“事件流”开始,从更广泛的意义上来说,事件是由一个源生成的数据,该数据由将在某种级别上处理该数据的消费者捕获或接收。事件源的一些例子是传感器、数据库改变、网络挂钩、应用调用等等。它们都生成可以触发其他应用的数据。流意味着这些事件持续发生,并且有高容量的可能性。

要理解分布式的部分,首先需要理解 Kafka 的架构。

概念和架构

围绕 Kafka 有很多炒作。大型科技公司正在使用它,他们说他们每天处理数十亿次交易,移动数万亿字节的数据,这显然会引起需要为其内部服务通信构建弹性和高性能解决方案的开发人员和架构师的注意。所以让我们了解 Kafka 到底是什么,然后你就可以得出你自己的结论。

Kafka 是 LinkedIn 在 2011 年成为开源之前创建的。它最初是为处理大数据流而设计的,比如跟踪页面浏览事件,从服务中收集聚合日志。你可能会问自己,为什么 LinkedIn 会创建一个市场上有这么多可用的消息系统?它需要一个强大且可扩展的平台,以非常低的延迟处理非常高的容量,因此必须做出一些重大的设计决策来实现这一点。

Kafka 只提供持久的话题。如前所述,您可能需要不同的策略来允许持久主题,因为很难同步消费者如何阅读主题中的消息,所以基于阅读来删除它们变得很复杂。在 Kafka 中,消息不会被消费,而是根据存储利用率或消息的持续时间进行轮换。

这个单一的设计选择开启了新的可能性。由于一切都是一个主题,我们可以根据需要向目的地添加更多的消费者,消费者将能够获得他们开始订阅之前就存在的消息。虽然这可能是某些情况下的期望行为,但对于消费者不希望接收旧消息的其他情况,这可能不是期望的行为。如果现在的消费者下线了呢?它将如何从它开始的地方恢复?Kafka 使用名为 offset 的结构保存消息索引,如图 5-3 所示。

img/514395_1_En_5_Fig3_HTML.jpg

图 5-3

偏移表示

偏移是 Kafka 用来标记消息位置的连续长值。它们用于识别哪个消息是为某个消费者群体阅读的(稍后我们将讨论消费者群体)。

Kafka 的信息处理有些不同。主题分为个分区。每个分区都是存储主题消息的独立结构。为了实现这个解释,想象一个有两个代理(Kafka 实例)的 Kafka 集群,如图 5-4 所示。

img/514395_1_En_5_Fig4_HTML.jpg

图 5-4

Kafka 分区分布

在这个例子中,有一个主题,两个分区分布在两个代理之间。每个分区都是独立的,接收来自生产者的隔离写入,并为消费者提供读取。每个分区都有自己的偏移量计数,该偏移量用于使用者组识别哪个消息在该分区中被读取。消费者组允许同一个消费者应用的不同实例并行读取,而没有重复。在本例中,使用者组中的每个使用者都被分配到一个分区,只有该使用者可以从该分区中读取数据。如果该消费者退出,并且为同一消费者组分配了新的消费者,则新的消费者可以从旧消费者停止的地方开始读取。

如你所见,Kafka 中的主题本质上是一组分区。分区是允许并行和分布式处理的很好的设计选择。我们在主题创建中设置我们需要的分区数量,但是我们也可以在以后添加更多的分区。分区的数量是根据预期的容量、预期的响应时间、集群安排、消费者行为、存储空间等决定的。这里的重要思想是调整这种配置的灵活性,以满足我们的系统需求。

有许多低层次的配置也会影响性能,但我们这里的重点是使 Kafka 成为一个非常有趣的消息传递解决方案的设计选择。

继续我们对 Kafka 描述中“分布式”含义的转换,你需要理解如何组装一个集群。看一下图 5-5 。

img/514395_1_En_5_Fig5_HTML.jpg

图 5-5

Kafka 集群

Kafka 实例在启动时做的第一件事就是在 Zookeeper 实例中注册自己。Zookeeper 是分布式应用的协调服务,这意味着它提供命名、配置管理、同步和组服务等公共服务。Kafka 利用 Zookeeper 来管理关于集群成员、主题、访问控制列表和一个非常重要的任务的信息,即 Kafka 控制器的选举,Kafka 实例将成为集群自动化背后的大脑。

2 . 8 . 0 版本提供了 KIP-500 的早期访问版本,允许你在没有 Apache ZooKeeper 的情况下运行 Kafka brokers,但这不是生产就绪,这就是为什么我在这里谈论 ZooKeeper。

Zookeeper 是这个架构中的一个重要部分,因为它通过提供协调来实现集群的高可用性。动物园管理员也是哈。它利用一个选举过程,一旦选出一个领导者,传入的写请求总是由该领导者处理,该领导者向所有可用节点请求相同的写。如果达到法定人数,则该请求被视为成功。

当我们谈论系统中的高可用性时,我们不仅仅是在谈论服务可用性,还包括数据可用性,尤其是对于一个行为类似于数据库的系统。Kafka 用一种叫做复制品的功能解决了这种必要性。看图 5-6 检查 Kafka 如何复制分区数据。

img/514395_1_En_5_Fig6_HTML.jpg

图 5-6

Kafka 复制品

在上面的例子中,有一个主题,它有三个分区,分布在三个代理之间。新的是每个分区都是复制的,所以是三个分区两个副本的题目。

我之前说过,每个分区都是独立的,并且将接收独立的写请求,这仍然是正确的,即使我们有多个副本。所发生的是控制器必须选择一个分区副本作为领导者。只有领导者接收写入和读取的请求,它负责向追随者发送写入请求以保持副本的一致性。这一过程受到高度监控,其状态通过 ISR (同步副本)值来表示。您将在几页中看到该值的显示。理想情况下,分区副本不会在同一个代理中,这样我们就有了数据冗余以防代理失败。

继续上面的例子,假设一个经纪人倒下了。我们会遇到如图 5-7 所示的情况。

img/514395_1_En_5_Fig7_HTML.jpg

图 5-7

代理失败

在这个例子中,partition 2领导者和partition 0跟随者在失败的代理中。假设最小 ISR 是 1,并且所有分区都是同步的,我们将有一个场景,在这个场景中,broker 1中的partition 2跟随者将成为领导者。partition 0将有一个同步的副本,即broker 0中的领导者。使用这种配置,不会丢失任何数据,并且该服务仍可用于所有分区。

还有更多的概念和可能的配置需要讨论,但这里的想法是在你开始使用它之前,对 Kafka 的架构以及它为什么有趣有一个基本的了解。这些是每个和 Kafka 一起工作的人需要理解的主要概念。

接下来,您将看到如何在本地安装和使用一个实例。

安装和运行

您已经了解了 Kafka 的基本概念,现在是时候尝试这个工具了。它可以被配置为创建巨大的集群,以支持大量生产者和消费者交换大量数据,但是,像往常一样,我们将从小处着手,使用一个可以在您的开发设置中轻松运行的示例。

您使用 Docker 来促进您对 Keycloak 的体验,您将对 Kafka 做同样的事情。不幸的是,该项目没有提供现成的容器映像,但我们可以指望社区为我们提供一个。您将使用wurstmeister/kafka映像。在kafka-installation文件夹下是本例中使用的映像的 git 项目,kafka-dockerzookeeper-docker,以防您想知道这些映像是如何构建的或者甚至想自己构建它们。在同一个文件夹中,你会发现一个docker-compose.yml文件,你将使用它来启动应用。在 IDE 或文本编辑器中打开该文件。我们来看一下;见清单 5-1 。

version: '3'
services:
  zookeeper:
    image: wurstmeister/zookeeper:latest
    container_name: zookeeper
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000
    ports:
      - 22181:2181

  kafka:
    image: wurstmeister/kafka:2.13-2.7.0
    container_name: broker
    depends_on:
      - zookeeper
    ports:
    - 9092:9092
    environment:
      KAFKA_ADVERTISED_HOST_NAME: localhost
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181

Listing 5-1Kafka docker-compose.yml

对于这个例子,您使用 Docker compose,因为 Kafka 需要一个 Zookeeper 实例来连接,所以您在文档中定义了两个服务,zookeeperkafka。对于 Kafka 如何工作以及使用它在应用之间交换消息的基本体验,您不需要集群,这就是为什么您要通过在每个服务中固定一个容器名称来设置这个合成文件只允许单个实例。它还将帮助您使用需要执行的 docker 命令。

让我们从 Kafka 开始。在终端中,导航到kafka-installation文件夹并运行以下命令:

kafka-installation $ docker compose up -d

一旦它完成下载映像,你应该得到一个类似图 5-8 的可视化效果。

img/514395_1_En_5_Fig8_HTML.jpg

图 5-8

坞站合成 up result

要检查 Kafka 是否正确启动,请使用以下命令查看容器日志:

$ docker logs broker

查找类似清单 5-2 的日志条目。如果你找到了他们,这意味着 Kafka 已经准备好了。

...
[2021-06-05 15:30:28,450] INFO [KafkaServer id=1001] started (kafka.server.KafkaServer)
[2021-06-05 15:30:28,506] INFO [broker-1001-to-controller-send-thread]: Recorded new controller, from now on will use broker 1001 (kafka.server.BrokerToControllerRequestThread)

Listing 5-2Kafka Container Log Snippet

Kafka 没有提供可视化工具或控制台,尽管有许多项目和供应商提供这种功能,但它提供了一组脚本,允许您管理它。

首先访问代理容器来可视化脚本集合。从终端运行以下命令:

$ docker exec -it broker /bin/sh

从这一点上,你可以导航到 Kafka 在这个映像中的安装位置。这里的安装路径是/opt/kafka。使用命令转到该文件夹:

/ # cd /opt/kafka/bin/

一旦在目录中,列出它。你会得到一个如图 5-9 的结果。

img/514395_1_En_5_Fig9_HTML.jpg

图 5-9

Kafka 的剧本

使用这些脚本可以进行许多不同的配置,但是您将只执行一些基本的操作,这些操作将帮助您利用 Kafka 进行消息传递。

第一步是创建一个主题。运行以下命令创建主题:

/opt/kafka_2.13-2.7.0/bin # kafka-topics.sh \
--bootstrap-server localhost:9092 --create \
--replication-factor 1 --partitions 2 --topic myTestTopic

预期结果是“Created topic myTestTopic.”消息。

您可以使用kafka-topics.sh查找更多与主题管理相关的操作,例如,列出集群中存在的主题:

/opt/kafka_2.13-2.7.0/bin # kafka-topics.sh \
--bootstrap-server localhost:9092 --list

该操作仅显示主题名称,以便您知道它们的存在,但是如果您需要关于主题的更多信息,您可以描述它,如以下命令所示:

/opt/kafka_2.13-2.7.0/bin # kafka-topics.sh \
--bootstrap-server localhost:9092 --describe \
--topic myTestTopic

如果您运行这个命令,您会得到如图 5-10 所示的结果。

img/514395_1_En_5_Fig10_HTML.jpg

图 5-10

主题描述命令

这个命令显示了关于这个主题的许多重要信息,比如它的分区数量、复制因子以及它可能具有的特定配置,但是真正有趣的是第二行。它显示了分区在哪里,谁是领导者,副本在哪里,以及哪些副本是同步的。在本例中,有两个分区,但只有一个代理实例。在这种情况下,分区领导者位于 id 为 1001 的代理中(您可以在启动日志中看到这一点),因此每个分区都有一个副本,这就是分区领导者本身。

您也可以改变此实体配置。假设您想将主题的保持期设置为 10 秒。你可以这样做:

/opt/kafka_2.13-2.7.0/bin # kafka-configs.sh \
--bootstrap-server localhost:9092 \
--topic myTestTopic --alter --add-config retention.ms=10000

然后,您可以再次检查该主题,看看上面的命令是否有一些效果。再次运行describe命令,如图 5-11 所示。

img/514395_1_En_5_Fig11_HTML.jpg

图 5-11

改变话题

已应用更改。您可以看到该主题保留期有了新的配置。

测试安装

现在您已经有了一个正在运行的 Kafka broker,您需要开始使用应用测试它。稍后您将使用 Camel 访问 Kafka,但是首先让我们利用 Kafka 安装提供的一些应用。

我希望您仍然打开了带有代理容器终端。如果没有,就按照上一节的步骤,因为您将在那里使用两个脚本:kafka-console-producer.shkafka-console-consumer.sh.

先从设置消费 app 开始。在已经打开的终端的kafka/bin目录中,运行以下命令:

/opt/kafka_2.13-2.7.0/bin # kafka-console-consumer.sh \
--bootstrap-server localhost:9092 --topic myTestTopic

此时命令行处于冻结状态,等待消息开始进入主题,因此让我们准备好生产者。打开一个新的终端,访问代理容器,并转到/opt/kafka/bin目录。像这样运行生产者脚本:

/opt/kafka_2.13-2.7.0/bin # kafka-console-producer.sh--bootstrap-server localhost:9092 --topic myTestTopic

将为您打开一个光标。键入一条消息,然后按 Enter。然后检查你的消费者。它会收到你发送的准确信息。

通过按 Control + C 停止消费者和生产者。您现在将执行一个新的测试。首先,让我们增加保留期,将其设置回默认值,即 7 天。运行以下命令:

/opt/kafka_2.13-2.7.0/bin # kafka-configs.sh \
--bootstrap-server localhost:9092 --topic myTestTopic \
--alter --config retention.ms=60480000

再次打开生成器。发送三条这样的消息:

  • 编译

  • 生产者

  • 消费者

现在像以前一样打开消费者。你不会得到任何消息。发生这种情况是因为该使用者被配置为仅读取新消息。添加选项-from-beginning,你将得到这些信息,如图 5-12 。

img/514395_1_En_5_Fig12_HTML.jpg

图 5-12

读取旧偏移

本节展示了如何使用 Kafka 作为消息代理来连接应用。这些脚本可以帮助您进行调试,因为它们可以接收不同的配置,比如使用特定的分区或使用给定的组 id。

Camel 和 Kafka

您刚刚学习了 Kafka 的概念和架构,如何执行基本操作,以及如何运行基本测试。现在,您将连接一个 Camel 应用,并讨论这种实现的一些注意事项。

在第一个使用 Camel 访问 Kafka 主题的例子中,您将使用两个不同的应用:camel-kafka-producercamel-kafka-consumer。生产者将公开一个 REST 接口,使您能够向消费者应用发送消息。通过这个简单的设置,您将探索使用 Kafka 组件的生产者和消费者的一些配置。

设置应用

让我们看看如何配置 Camel 应用来访问 Kafka 主题。

从分析camel-kafka-producer代码开始。将它加载到您喜欢的 IDE 中。查看清单 5-3 中的 pom 文件。

...
<dependencies>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-rest</artifactId>
</dependency>
</dependencies>
...

Listing 5-3pom.xml Snippet

这个项目只有两个依赖项,camel-quarkus-restcamel-quarkus-kafka。你已经知道的 REST 组件。它将负责添加 webserver 实现并启用 REST DSL。Kafka 成分是你的研究对象。让我们看看这个项目路线是什么样子的。打开清单 5-4 所示的RestKafkaRoute.java文件。

public class RestKafkaRoute extends RouteBuilder {

@Override
public void configure() throws Exception {

  rest("/kafka")
  .consumes(TEXT_PLAIN)
  .produces(TEXT_PLAIN)
  .post()
  .route()
    .routeId("rest-route")
    .log("sending message.")
    .removeHeaders("*")
    .to("{{kafka.uri}}")
    .removeHeaders("*")
    .setBody(constant("message sent."))
    .log("${body}")
  .endRest();
}}

Listing 5-4RestKafkaRoute.java File

这个路由接收到一个text/plain体,发送到一个 Kafka 主题,得到一个响应通知消息已经发送到主题,但是这个逻辑中间有一个你以前没有做过的事情。就在 Kafka 组件步骤之前,您正在使用一个无所不包的模式删除消息头。这是您以前没有做过的事情,因为您面临的案例没有受到标头传播的影响。

根据组件的不同,它可能会对一些特殊的头做出反应,例如 JPA 查询参数,但是对于这个组件,其他头会被完全忽略。在这种情况下,您正在使用一个对消息头更敏感的组件,因为被调用的应用在其数据模型中有消息头。

Kafka 消息(也称为记录)是带有一些元数据和消息头的键值对条目。该键是用于分区分配的可选字段。该值是您想要发送的实际消息。

在这个例子中,您删除了来自 HTTP 请求的所有 of 头,因为您不需要它们,也不想向 Kafka 发送无用的信息。在组件调用之后,您还要删除头,因为您不需要返回的信息,也不想将 Kafka 信息暴露给 HTTP 客户端。

您可能已经注意到了用于声明端点配置的属性键。因为这个组件比前面的例子需要更多的配置,所以使用application.properties文件来提高代码的可读性和可配置性是有意义的。

在 IDE 中打开属性文件(清单 5-5 )。

topic=example1
brokers=localhost:9092
id=producer
kafka.uri=kafka:{{topic}}?brokers={{brokers}}&clientId={{id}}

Listing 5-5application.properties File

Kafka 组件需要一些参数才能正常工作。第一个是它将向其发送消息的topic。在这里,您将设置“example1”作为值。稍后您将创建这个主题。你还需要设置brokers地址。如果要连接到集群,则必须在此参数中配置一个列表。Kafka 客户端需要知道集群的所有成员,以便根据分区分布或负载均衡来访问它们。这种情况下的clientId会帮助 Kafka 和你追踪通话。

现在,让我们看一下消费者应用。在您的 IDE 中打开camel-kafka-consumer项目。看清单 5-6 中的RouteBuilder

public class KafkaConsumerRoute extends RouteBuilder {

    @Override
    public void configure() throws Exception {

      from("{{kafka.uri}}")
      .routeId("consumer-route")
      .log("Headers : ${headers}")
      .log("Body : ${body}");
    }
}

Listing 5-6KafkaConsumerRoute.java File

这个路由使用来自一个主题的消息并记录消息内容,首先是消息头,然后是消息内容。

让我们看看清单 5-7 中该项目的application.properties

topic=example1
brokers=localhost:9092
kafka.uri=kafka:{{topic}}?brokers={{brokers}}&clientId=${kafka.clientid}&groupId=${kafka.groupid}

Listing 5-7camel-kafka-consumer Project’s application.properties File

这种配置与您之前看到的类似。唯一不同的是,对于消费者,建议设置一个groupId。组 id 允许您持久化读取的偏移量,因此如果您重新启动消费者应用,它将能够从停止的地方重新启动。clientIdgroupId使用属性标记作为值,因为您将把这些参数作为 JVM 变量传递给应用。

首次测试

您已经看到了示例应用是如何配置的。现在你需要看看它们在不同的场景下会有怎样的表现。

让我们测试代码。在运行应用之前,您需要创建“example1”主题。您可以运行以下命令来完成此操作:

docker exec -it broker /opt/kafka/bin/kafka-topics.sh \
--create --bootstrap-server localhost:9092 \
--replication-factor 1 --partitions 2 --topic example1

创建主题后,您就可以启动消费者了。在终端的camel-kafka-consumer目录下,运行以下命令:

camel-kafka-consumer/ $ mvn quarkus:dev -Dkafka.clientid=test  -Dkafka.groupid=testGroup

消费者启动后,查看消费者日志。那里有一些有趣的信息。它们看起来会像这样:

[Consumer clientId=test, groupId=testGroup] Notifying assignor about the new Assignment(partitions=[example1-0, example1-1])

日志条目通过分配过程中的clientIdgroupId来标识消费者,该过程确定消费者组的成员将从哪个分区获取消息。在这种情况下,由于您只有一个使用者和两个分区,因此使用者将从两个分区获得消息。

[Consumer clientId=test, groupId=testGroup] Found no committed offset for partition example1-0
[Consumer clientId=test, groupId=testGroup] Found no committed offset for partition example1-1

上面的消息告诉您,没有为两个分区保存偏移读数。

[Consumer clientId=test, groupId=testGroup] Resetting offset for partition example1-0 to position FetchPosition{offset=0, offsetEpoch=Optional.empty, currentLeader=LeaderAndEpoch{leader=Optional[localhost:9092 (id: 1001 rack: null)], epoch=0}}.

[Consumer clientId=test, groupId=testGroup] Resetting offset for partition example1-1 to position FetchPosition{offset=0, offsetEpoch=Optional.empty, currentLeader=LeaderAndEpoch{leader=Optional[localhost:9092 (id: 1001 rack: null)], epoch=0}}.

这些条目告诉您将从哪个偏移量开始读取消息,在本例中为offset=0。它们还告诉给定分区的领导者是谁,在本例中是localhost:9092 (id: 1001)

你现在可以开始制作了。在新终端中,运行以下命令:

camel-kafka-producer/ $ mvn quarkus:dev -Ddebug=5006

启动生成器后,您可以向它发送请求。发送以下请求:

$ curl  -X POST 'http://localhost:8080/kafka'   \
-H 'Content-Type: text/plain' -d 'Testing Kafka'

看看消费者记录。你会得到这样的东西:

2021-06-06 08:47:42,524 INFO  [consumer-route] (Camel (camel-1) thread #0 - KafkaConsumer[example1]) Headers : {kafka.HEADERS=RecordHeaders(headers = [], isReadOnly = false), kafka.OFFSET=0, kafka.PARTITION=1, kafka.TIMESTAMP=1622980062458, kafka.TOPIC=example1}

2021-06-06 08:47:42,528 INFO  [consumer-route] (Camel (camel-1) thread #0 - KafkaConsumer[example1]) Body : Testing Kafka

第一个日志条目是消息头的值。正如您所看到的,消费者将返回一些关于读取的信息,比如偏移读取、哪个分区、哪个主题和消息时间戳,这将告诉您消息何时进入主题。第二个值是实际的消息。

让我们用不同的信息再试一次:

$ curl  -X POST 'http://localhost:8080/kafka'\
 -H 'Content-Type: text/plain' -d 'Learning Camel'

再看看消费者日志。您将看到类似这样的条目:

2021-06-06 08:49:22,988 INFO  [consumer-route] (Camel (camel-1) thread #0 - KafkaConsumer[example1]) Headers : {kafka.HEADERS=RecordHeaders(headers = [], isReadOnly = false), kafka.OFFSET=0, kafka.PARTITION=0, kafka.TIMESTAMP=1622980162982, kafka.TOPIC=example1}

2021-06-06 08:49:22,989 INFO  [consumer-route] (Camel (camel-1) thread #0 - KafkaConsumer[example1]) Body : Learning Camel

该分区与之前的测试不同,但是消费者仍然可以从两个分区获得消息。让我们在同一个消费者组中添加一个新的消费者。

打开新的终端。导航到camel-kafka-consumer项目目录并运行以下命令:

camel-kafka-consumer/ $ mvn quarkus:dev -Dkafka.clientid=other  -Dkafka.groupid=testGroup -Ddebug=5007

一旦应用启动,查看它的日志。以下是我的例子:

[Consumer clientId=other, groupId=testGroup] Adding newly assigned partitions: example1-0

[Consumer clientId=other, groupId=testGroup] Setting offset for partition example1-0 to the committed offset FetchPosition{offset=1, offsetEpoch=Optional.empty, currentLeader=LeaderAndEpoch{leader=Optional[localhost:9092 (id: 1001 rack: null)], epoch=0}}

分区 0 是为我的新用户分配的,它将从offset=1开始读取。如果您查看第一个消费者的日志,您会看到现在只有一个分区被分配给它。

扩大消费者规模

我们讨论了 Kafka 架构的可伸缩性,但是在这种情况下,可伸缩性也意味着增加消费者端的处理能力。当考虑如何衡量消费者时,我们需要遵循一些规则。

如果我们在同一个组中添加一个新的消费者,会发生什么情况?让我们试试。在camel-kafka-consumer目录中打开一个新的终端,并运行以下命令:

camel-kafka-consumer/ $ mvn quarkus:dev -Dkafka.clientid=third  -Dkafka.groupid=testGroup -Ddebug=5008

应用启动后,我的新消费者就遇到了这种情况:

[Consumer clientId=third, groupId=testGroup] Notifying assignor about the new Assignment(partitions=[])

没有给它分配分区。让我们来看看当您向主题添加消息时会发生什么。运行下面的 bash 脚本,在主题中输入十条新消息。

$ i=0; while [ $i -lt 10 ]; do ((i++)); curl -w "\n" -X POST 'http://localhost:8080/kafka' -H 'Content-Type: text/plain'   -d "Message number: $i" ; done

如果您查看消费者的日志,您将看到没有分配分区的消费者没有接收消息。这被称为饥饿的消费者。

请记住,活动消费者的数量将取决于该主题当前可用的分区数量。在代理失败的情况下,我们可能会遇到某个特定分区没有领导者的情况。

如果您需要提高整体性能,请记住,您可以稍后向一个主题添加更多的分区。

您可以使用其他客户端配置来提高消费者处理能力,比如consumersCountconsumerStreams

consumerStreams参数负责设置组件线程池的线程数量,而consumersCount负责设置应用中 Kafka 消费者的数量。每个偏移量读取都将在一个线程中完成,这意味着您可以进行的并发读取的数量将取决于您拥有的消费者数量,以及您是否有可供该消费者使用的线程。

为了演示这个配置,在您的 IDE 上打开camel-kafka-consumer-v2项目。让我们看看这个项目的路线,这样你就可以了解这个测试是如何工作的;看清单 5-8 。

public class KafkaConsumerRoute extends RouteBuilder {

@Override
public void configure() throws Exception {

from("{{kafka.uri}}")
 .routeId("consumer-route")
 .log("Headers : ${headers}")
 .log("Body : ${body}")
 .process(new Processor() {
    @Override
 public void process(Exchange exchange) throws Exception {
  log.info("My thread is :"+Thread.currentThread().getName());
  log.info("Going to sleep...");
  Thread.sleep(10000);
    }
  });
}}

Listing 5-8KafkaConsumerRoute.java File

这条路线上唯一的新东西是,现在您有了一个处理器,它让正在执行的线程休眠十秒钟。这将有助于你想象执行过程。

让我们看看清单 5-9 中的属性文件。

topic=example1
brokers=localhost:9092
kafka.uri=kafka:{{topic}}?brokers={{brokers}}&clientId=${kafka.clientid}&groupId=${kafka.groupid}&consumersCount=${kafka.consumers.count}&consumerStreams=${kafka.consumers.stream}

Listing 5-9camel-kafka-consumer-v2 properties File

这里您添加了consumersCountconsumerStreams参数,但是您也将从 JVM 属性中获得它们。

要开始测试此代码,请停止任何正在运行的使用者。在终端中,像这样启动应用:

camel-kafka-consumer-v2/ $ mvn clean quarkus:dev \
-Ddebug=5006 -Dkafka.clientid=test -Dkafka.groupid=testGroup \ -Dkafka.consumers.count=1 -Dkafka.consumers.stream=10

在这里,您可以使用默认值设置参数。要测试应用如何使用消息,请在另一个终端中运行以下命令:

$ i=0; while [ $i -lt 3 ]; do ((i++));  curl -w "\n"  -X POST 'http://localhost:8080/kafka' -H 'Content-Type: text/plain'   -d "Message number: $i" ; done

三条消息足以向您显示每十秒钟将处理一条消息,即使您在池中有十个线程。

现在尝试使用两个 Kafka 消费者和池中相同数量的线程。停止消费者,然后像这样重新启动它:

camel-kafka-consumer-v2/ $ mvn clean quarkus:dev \            -Ddebug=5006 -Dkafka.clientid=test -Dkafka.groupid=testGroup \
-Dkafka.consumers.count=2 -Dkafka.consumers.stream=10

现在,不是只发送三条信息,而是发送八条。

$ i=0; while [ $i -lt 8 ]; do ((i++));  curl -w "\n"  -X POST 'http://localhost:8080/kafka' -H 'Content-Type: text/plain'   -d "Message number: $i" ; done

您将看到应用每十秒钟消耗两条消息。但是如果你的消费者比线程多会怎么样呢?停止消费应用,并像这样启动它:

camel-kafka-consumer-v2/ $ mvn clean quarkus:dev \
-Ddebug=5006 -Dkafka.clientid=test -Dkafka.groupid=testGroup \          -Dkafka.consumers.count=2  -Dkafka.consumers.stream=1

再发三条信息。您将看到每十秒钟只处理一条消息。

您在这里所做的是通过允许消费者消耗更多资源来纵向扩展消费者,在本例中是分配给分区。当您纵向扩展应用时,您还需要调整应用消耗计算资源(如内存和 CPU)的方式。

您正在对应用采用微服务方法,因此您不希望它们变得太大,以至于可能会损害其他重要的微服务特性,如正常关闭的敏捷性或水平扩展的能力(通过添加新实例)。这完全是了解您的数据和您的应用,然后适当调整配置的问题。测试是必须的。

偏移复位

您可以为已经包含消息的现有主题添加新的消费者。在这种情况下,你需要为你的新消费者设定正确的行为。

在讨论当一个主题已经有消息时你能做什么之前,我需要你看一看一些东西。在代理运行的情况下,在终端上运行以下命令:

$ docker exec -it broker /opt/kafka/bin/kafka-topics.sh                  --bootstrap-server localhost:9092 --list

这个命令列出了代理中可用的主题。如果你没有删除任何主题,你的输出应该如图 5-13 所示。

img/514395_1_En_5_Fig13_HTML.jpg

图 5-13

主题列表

组 id 消耗的每个分区偏移量保存在__consumer_offsets主题中。您可以在应用日志中看到,在每次启动过程中,客户端都会检查给定分区的可用偏移量,总是寻找最新的引用。

在开始这个新例子之前,您需要一个新的主题。来清理一下你的老话题吧。停止任何正在运行的应用,并运行以下命令来执行此操作:

$ docker exec -it broker /opt/kafka/bin/kafka-topics.sh       --bootstrap-server localhost:9092 --delete --topic myTestTopic


$ docker exec -it broker /opt/kafka/bin/kafka-topics.sh       --bootstrap-server localhost:9092 --delete --topic example1

要开始您的测试,您将需要一个主题,但是这次不是您自己创建一个,而是让主题自动创建来完成。默认情况下,它将创建一个具有单个副本和单个分区的主题。这对你的测试来说足够了。

如果这不是理想的配置,您可以通过将 Kafka 的 config 目录中的server.properties文件中的auto.create.topics.enable属性设置为 false 来禁用自动创建。这将需要重新启动代理。

再次运行camel-kafka-producer应用。启动后,使用以下命令发送十条消息:

$ i=0; while [ $i -lt 10 ]; do ((i++));  curl -w "\n"  -X POST 'http://localhost:8080/kafka' -H 'Content-Type: text/plain'   -d "Message number: $i" ; done

您现在可以启动消费者,知道主题中有消息。像这样启动camel-kafka-consumer应用:

camel-kafka-consumer/ $ mvn clean quarkus:dev -Ddebug=5006     -Dkafka.clientid=test -Dkafka.groupid=testGroup

你没有收到任何信息,是吗?这是意料之中的行为。默认情况下,自动偏移重置的组件属性设置为采用分区中的最新偏移。查看消费者应用中的日志,如清单 5-10 所示。

2021-06-06 20:52:09,669 INFO  [org.apa.kaf.cli.con.int.ConsumerCoordinator] (Camel (camel-1) thread #0 - KafkaConsumer[example1]) [Consumer clientId=test, groupId=testGroup] Found no committed offset for partition example1-0

2021-06-06 20:52:09,693 INFO  [org.apa.kaf.cli.con.int.SubscriptionState] (Camel (camel-1) thread #0 - KafkaConsumer[example1]) [Consumer clientId=test, groupId=testGroup] Resetting offset for partition example1-0 to position FetchPosition{offset=10, offsetEpoch=Optional.empty, currentLeader=LeaderAndEpoch{leader=Optional[localhost:9092 (id: 1001 rack: null)], epoch=0}}.

Listing 5-10camel-kafka-consumer Application Log Snippet

第一个日志显示,对于所提供的组 id,没有找到分区example1-0的提交偏移量。第二个显示为FetchPosition offset=10重置了偏移,这将是下一个生成的偏移。

发送一条消息来检查消费者日志中的记录标题:

$ curl -w "\n"  -X POST 'http://localhost:8080/kafka'-H 'Content-Type: text/plain'   -d "Single message"

在日志中,您会发现如下条目:

2021-06-06 20:57:16,338 INFO  [consumer-route] (Camel (camel-1) thread #0 - KafkaConsumer[example1]) Headers : {kafka.HEADERS=RecordHeaders(headers = [], isReadOnly = false), kafka.OFFSET=10, kafka.PARTITION=0, kafka.TIMESTAMP=1623023836299, kafka.TOPIC=example1}

您可以看到消息偏移量是10,这是在消费者启动中标记的位置,但是您仍然没有获得之前的消息。这可能是所希望的情况,因为新的应用可能不想要旧的消息。如果您想要重放主题中的每条消息,您只需要一个新的组 id 和以下配置。

首先,停止消费者应用。将kafka.uri属性更改为如下所示:

kafka.uri=kafka:{{topic}}?brokers={{brokers}}&clientId=${kafka.clientid}&groupId=${kafka.groupid}&autoOffsetReset=earliest

现在,像这样启动应用:

camel-kafka-consumer/ $ mvn clean quarkus:dev-Ddebug=5006    -Dkafka.clientid=test -Dkafka.groupid=newGroup

这样,应用将获得主题中存在的所有消息,如果您使用相同的参数重新启动应用,它将不会获得相同的消息,因为提供的组 id 已经保存了一个偏移量,客户端不需要重置它。

单元测试应用

对于单元测试应用,保持代码质量并使维护更容易是很重要的。在处理集成时,您可能会遇到不容易模仿的应用,比如消息代理。Camel 提供的组件和功能可以帮助您完成这项任务。

对于这个新例子,有一个名为camel-kafka-tests的新项目。这个项目融合了camel-kafka-producercamel-kafka-consumer plus 单元测试。

它有一个RouteBuilder来公开一个 REST 接口并将一条消息发布到一个主题中,还有一个RouteBuilder来为该主题创建一个消费者。先说清单 5-11 中的生产商路线。

public class RestKafkaRoute extends RouteBuilder {

@Override
public void configure() throws Exception {

  rest("/kafka")
  .consumes(TEXT_PLAIN)
  .produces(TEXT_PLAIN)
  .post()
  .route()
    .routeId("rest-route")
    .log("sending message.")
    .removeHeaders("*")
    .to("{{kafka.uri.to}}")
    .removeHeaders("*")
    .setBody(constant("message sent."))
    .log("${body}")
  .endRest();

}
}

Listing 5-11RestKafkaRoute.java File

这条路线实际上就是您一直用来在主题中发布消息的路线。唯一的区别是现在属性名不那么通用了,因为现在有两条路由。让我们看看清单 5-12 中的消费者。

public class KafkaConsumerRoute extends RouteBuilder {

    @Override
    public void configure() throws Exception {

        from("{{kafka.uri.from}}")
        .routeId("consumer-route")
        .log("Headers : ${headers}")
        .to("{{final.endpoint}}");

    }
}

Listing 5-12KafkaConsumerRoute.java File

唯一改变的是最后一步。您将使用日志端点,而不是使用log() DSL 进行日志记录。你马上就会明白为什么了。

为了测试这段代码,您需要首先创建主题。奔跑

$ docker exec -it broker /opt/kafka/bin/kafka-topics.sh  \     --create --bootstrap-server localhost:9092  \                 --replication-factor 1 --partitions 2 --topic example2

然后,您可以启动应用:

camel-kafka-tests/ $ mvn quarkus:dev

并发送消息进行测试:

$ curl -X POST 'http://localhost:8080/kafka' \
-H 'Content-Type: text/plain' -d "hi"

既然您已经看到了应用是如何工作的,那么您可以开始关注单元测试了。观察项目 pom 的新增内容,如清单 5-13 所示。

...
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-log</artifactId>
</dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-direct</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-mock</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
...

Listing 5-13camel-kafka-tests pom.xml File Snippet

我只强调这个例子中新增加的内容。

你已经知道camel-quarkus-log。您之所以使用它,是因为您也在使用组件进行日志记录。您将使用camel-quarkus-directcamel-quarkus-mock来替代和模仿一些端点定义。这将为您提供在没有运行 Kafka 代理的情况下执行单元测试的灵活性。JUnit 5 的 Quarkus 实现是测试的基础。考虑到夸尔库斯建筑模型,它将正确地设置环境。最后,您可以放心,这是一个用于测试 REST 应用的流行库。

所以让我们来看看第一个单元测试类,如清单 5-14 所示。

@QuarkusTest
public class RestKafkaRouteTest {

    @Test
    public void test()  {

        given()
            .contentType(ContentType.TEXT)
            .body("Hello")
        .when()
            .post("/kafka")
        .then()
            .statusCode(200)
            .body(is("message sent."));

    }
}

Listing 5-14RestKafkaRouteTest.java File

首先用@QuarkusTest注释测试类。这将允许 JUnit 实现启动应用和其中的 Camel 上下文。测试非常简单。您使用 REST Assured 向 REST 端点发送一条消息,然后断言响应状态代码是否为 200,响应消息是否为"message sent."

你可能会问在路线中间的 Kafka 端点调用。这一呼吁遭到了嘲笑。看一下测试文件夹下的application.properties文件,如清单 5-15 所示。

kafka.uri.to=mock:kafka-topic
kafka.uri.from=direct:topic
final.endpoint=mock:destination

Listing 5-15application.properties for Test

在本例中,您使用属性声明端点,因此可以在测试时替换属性值。这样,您仍然可以对路由逻辑进行单元测试,而不必在测试期间提供代理。在这个测试中,模拟组件用于传递交换,没有任何修改。然后路由结束,HTTP 客户端将得到一个响应。

现在让我们看看清单 5-16 中的消费者路线。

@QuarkusTest
public class KafkaConsumerRouteTest {

    @Inject
    CamelContext camelContext;

    @Inject
    ProducerTemplate producerTemplate;

    @ConfigProperty(name = "kafka.uri.from")
    String direct;

    @ConfigProperty(name = "final.endpoint")
    String mock;

    private static final String MSG = "Hello";

    @Test
    public void test() throws InterruptedException {

        producerTemplate.sendBody(direct, MSG);

        MockEndpoint mockEndpoint =
           camelContext.getEndpoint(mock, MockEndpoint.class);
        mockEndpoint.expectedMessageCount(1);
        mockEndpoint.assertIsSatisfied();

        assertEquals(MSG,mockEndpoint.getExchanges().get(0)
                                 .getMessage().getBody())

    }

}

Listing 5-16KafkaConsumerRouteTest.java File

在这里,您使用不同的方法来测试路由,因为您的路由消费者必须被嘲笑。因为您没有运行代理,所以有必要用 Kafka 组件替换 direct 组件。这样,您可以使用ProducerTemplate来调用路由,这实际上将创建一个交换并将其发送到直接端点。从那里,路由逻辑将继续。这条路线的问题是它做的不多,这就是为什么你把最后一步改成使用to()。在单元测试中,您将替换模拟组件的日志定义,并使用它来检查路由状态。通过注入 Camel 上下文,可以获得端点引用。在这里,您可以获得对模拟端点的引用。这个端点将保留它收到的交换信息,使用这些信息,您可以断言路由是否按预期执行。您可以检查收到了多少条消息,以及消息正文是否与您发送的相同,因为不应该进行任何处理。

要执行这些测试,请运行以下命令:

camel-kafka-tests/ $ mvn clean test

预计会在日志中收到以下消息:

[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] -------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] -------------------------------------------------------

这里展示的技术可以用来设置许多不同的测试场景。例如,您可以用 bean 调用替换端点定义,并使用这些方法在交换中注入您可以模仿的任何对象。这样,您可以虚拟地模仿您需要做的每一个集成。只要记住保持路由逻辑的整洁,并使用属性来声明端点,单元测试 Camel 将会很容易。

摘要

本章致力于服务之间的异步通信,最常见的方式是使用面向消息的中间件。在本章中,您学习了以下主题:

  • 面向消息的中间件的特征

  • 异步通信对健壮架构的重要性

  • Kafka 的概念和建筑

  • 如何使用 Kafka 运行和执行基本配置

  • 如何设置 Camel 访问 Kafka 主题

  • 如何使用 Quarkus 和 Camel 执行单元测试

在下一章也是最后一章,我们将深入探讨如何在 Kubernetes 环境中运行这些所谓的云原生应用,以及这种方法对架构设计的影响。