架构聚焦—事件流技术Apache Kafka的工作代码实现

234 阅读13分钟

前面的内容

在上一篇架构聚焦文章中,我们讨论了事件源,并以一个简单的银行账户为例说明了这个概念。我们阐述了它的许多优点和缺点,以帮助读者决定这种模式是否对他们有用。

在这篇文章中,我们将扩展这个例子,并展示一个使用流行的事件流技术--Apache Kafka的工作代码实现。

为什么是Kafka?

Kafka提供了持久的消息传递基础设施和键/值数据存储,在一个单一的包中支持事件源和派生状态存储。这是构成一个真实世界的事件源应用程序的两个主要支柱。Kafka的消息传递主题的中心结构被用来支持这两个概念--它的存储API作为其主题API之上的一个更高层次的结构。

然而,事件源并不是Kafka的唯一用途。它最初是为了支持事件流--大批量的消息传递、处理和分析--所以也可以作为你的微服务系统中的一般消息传递结构而工作。它很容易扩展,并通过其可扩展性,为你通过它发送的消息提供分区、复制的存储。

构建基本实例的脚手架

我们选择了几个抽象,以减少模板,使我们能够专注于事件源的实现。我们将使用Spring Cloud Stream来提供一个支持集成的微服务应用的基础,同时使用其Kafka和Kafka Streams绑定器来允许我们使用Kafka来操作。样本应用中的输入、输出和处理功能将被设计成Spring云函数,这既简化了处理代码,又可以在需要时更容易地部署到云供应商的无服务器基础设施上。我们还使用Lombok来减少领域对象的模板,并为我们提供随时可用的日志,以及Jackson来处理与JSON的数据交换。

本文描述的项目将使用Spring Cloud构建,因此第一步是从Spring Initializr下载一个项目支架。下载后,编辑项目的pom.xml,将Spring Cloud Stream Kafka绑定器添加到项目的依赖列表中:

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-stream-binder-kafka</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-stream-binder-kafka-streams</artifactId>
    </dependency>
    ...

现在你可以在命令行中构建并运行该项目,如下所示(在Windows平台上用mvnw代替mvnw.cmd):

./mvnw clean package && java -jar target/es-example*.jar

运行该程序将显示典型的maven构建输出(包括测试执行),然后是运行中的应用程序的日志输出。首先,日志输出将包括spring boot的logo和一些日志信息,表明应用程序的初始启动,然后关闭,因为现在的应用程序不包含运行时处理逻辑。

运行Kafka

我们还需要一个正在运行的Kafka实例,我们的应用程序可以与之交互,以发送和接收消息,并访问我们的持久性衍生状态存储。

最简单的方法是通过使用docker-compose在本地启动。一个合适的docker-compose.yml文件是可用的(还有其他几个Spring Cloud Stream Kafka例子供你参考)。一旦你下载了这个docker-compose.yml文件到你的项目,只需运行即可:

docker-compose up -d

运行这个文件将启动一个zookeeper实例(Kafka需要它来协调其集群),以及Kafka本身。

添加域模型

在我们向示例应用程序添加一些处理逻辑之前,我们将需要一些领域对象来为我们的命令、事件和银行账户实体建模。这些对象将代表我们在Kafka上作为消息传递的数据,以及一般的状态容器,并成为我们处理逻辑的操作对象。

实体

我们要处理的基本数据实体是一个银行账户,由BankAccount类描述。注意,我们在这里包含了一个账户ID,因为Kafka需要一种方法来通过某种形式的密钥来关联消息。所有具有相同密钥的消息都被认为是对同一数据对象进行操作。较新的消息也可以代表指定对象的完整更新状态,这取决于你的系统是如何设计的。Kafka还将所有具有相同密钥的消息存储在一个给定的节点上,以提高处理该特定数据对象的完整消息历史时的效率:

package com.example.esexample.model.entities;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import lombok.Builder;
import lombok.Value;

@Value
@JsonDeserialize(builder = BankAccount.BankAccountBuilder.class)
@Builder(builderClassName = "BankAccountBuilder", toBuilder = true)
public class BankAccount {

   String accountId;
   Long balance;

   @JsonPOJOBuilder(withPrefix = "")
   public static class BankAccountBuilder {}

}
package com.example.esexample.model.entities;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import lombok.Builder;
import lombok.Value;

@Value
@JsonDeserialize(builder = BankAccount.BankAccountBuilder.class)
@Builder(builderClassName = "BankAccountBuilder", toBuilder = true)
public class BankAccount {

   String accountId;
   Long balance;

   @JsonPOJOBuilder(withPrefix = "")
   public static class BankAccountBuilder {}

}

事件

我们选择了一个单一的事件对象和一个枚举来区分多种事件类型,然而,这绝不是对这样一个领域进行建模的唯一方法。

事件类型枚举:

package com.example.esexample.model.events;

public enum AccountEventType {

   ACCOUNT_OPENED, ACCOUNT_CREDITED, ACCOUNT_DEBITED

}

除了信贷和借贷事件外,我们还包括 ACCOUNT_OPENED.这被用作 "打开 "一个账户的标记,意味着在系统中创建了一个初始账户对象(起始余额为零)。贷记和借记事件只能在一个已经存在的账户上操作。

账户事件由一个AccountEvent 类表示:

package com.example.esexample.model.events;

import com.example.esexample.model.entities.BankAccount;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import lombok.Builder;
import lombok.Value;

@Value
@JsonDeserialize(builder = AccountEvent.AccountEventBuilder.class)
@Builder(builderClassName = "AccountEventBuilder", toBuilder = true)
public class AccountEvent {

   String accountId;
   AccountEventType eventType;
   Long amount;
   BankAccount bankAccount;

   @JsonPOJOBuilder(withPrefix = "")
   public static class AccountEventBuilder {}

}

注意该事件中包含了最新的银行账户状态的表示。这样做只是为了时间点处理的方便,绝不是必须的。对于事件源,关键是要记住,所有事件的序列最终代表主状态记录。任何其他的状态表示纯粹是为了方便。

命令

与事件类似,我们使用一个带有枚举的单一容器对象来区分命令类型。这个枚举也包含了大部分的处理逻辑,将一个命令应用于一个账户,并通过这样做产生一个事件。

命令类型枚举:

package com.example.esexample.model.commands;

import com.example.esexample.model.entities.BankAccount;
import com.example.esexample.model.events.AccountEvent;
import com.example.esexample.model.events.AccountEventType;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public enum CommandType {

   DEPOSIT {
       @Override
       protected AccountEvent processForAccount(String accountId, Long amount, BankAccount bankAccount) {
           validateAccountPreconditions(accountId, amount, bankAccount);

           log.info("Processing {} (amount: {}) for account {}", this, amount, bankAccount);

           final BankAccount newAccountState = BankAccount.builder().accountId(bankAccount.getAccountId())
                   .balance(bankAccount.getBalance() + amount).build();

           return AccountEvent.builder().accountId(bankAccount.getAccountId())
                   .eventType(AccountEventType.ACCOUNT_CREDITED).amount(amount).bankAccount(newAccountState).build();
       }

       @Override
       protected void validateAccountPreconditions(String accountId, Long amount, BankAccount bankAccount) {
           if (!accountId.equals(bankAccount.getAccountId())) {
               throw new IllegalStateException("Command request is not for the specified account");
           }
       }
   }, WITHDRAW {
       @Override
       protected AccountEvent processForAccount(String accountId, Long amount, BankAccount bankAccount) {
           validateAccountPreconditions(accountId, amount, bankAccount);

           log.info("Processing {} (amount: {}) for account {}", this, amount, bankAccount);

           final BankAccount newAccountState = BankAccount.builder().accountId(bankAccount.getAccountId())
                   .balance(bankAccount.getBalance() - amount).build();

           return AccountEvent.builder().accountId(bankAccount.getAccountId())
                   .eventType(AccountEventType.ACCOUNT_DEBITED).amount(amount).bankAccount(newAccountState).build();
       }

       @Override
       protected void validateAccountPreconditions(String accountId, Long amount, BankAccount bankAccount) {
           if (!accountId.equals(bankAccount.getAccountId())) {
               throw new IllegalStateException("Withdrawal request is not for the specified account");
           }

           if (bankAccount.getBalance() < amount) {
               throw new IllegalStateException("Insufficient funds to process withdrawal request");
           }
       }

   };

   abstract protected AccountEvent processForAccount(String accountId, Long amount, BankAccount bankAccount);

   abstract protected void validateAccountPreconditions(String accountId, Long amount, BankAccount bankAccount);

   boolean canProcessForAccount(String accountId, Long amount, BankAccount bankAccount) {
       try {
           validateAccountPreconditions(accountId, amount, bankAccount);
           return true;
       } catch (IllegalStateException e) {
           return false;
       }
   }

}

上面特别感兴趣的是validateAccountPreconditions() 方法中的 WITHDRAW枚举值。这代表了业务逻辑,以检查处理命令的账户是否有足够的资金来成功提款。如果不能满足这个条件,会抛出一个IllegalStateException

命令容器对象:

package com.example.esexample.model.commands;

import com.example.esexample.model.entities.BankAccount;
import com.example.esexample.model.events.AccountEvent;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder;
import lombok.Builder;
import lombok.Value;

@Value
@JsonDeserialize(builder = Command.CommandBuilder.class)
@Builder(builderClassName = "CommandBuilder", toBuilder = true)
public class Command {

   String accountId;
   CommandType commandType;
   Long amount;

   public boolean canProcessForAccount(BankAccount bankAccount) {
       return null != commandType && commandType.canProcessForAccount(accountId, amount, bankAccount);
   }

   public AccountEvent processForAccount(BankAccount bankAccount) {
       return null == commandType ? null : commandType.processForAccount(accountId, amount, bankAccount);
   }

   @JsonPOJOBuilder(withPrefix = "")
   public static class CommandBuilder {}

}

实用类

一个Constants 类存储了整个应用中使用的常量:

package com.example.esexample.util;

public class Constants {

   public static final String UNITY_ACCOUNT_ID = "unity";

   public static final String HEADER_ACCOUNT_ID = "accountId";
   public static final String HEADER_EVENT_TYPE = "eventType";
   public static final String HEADER_CORRELATION_UUID = "correlationUuid";

   public static final String EVENT_STORE_ACCOUNTS = "stores.accounts";

   public static final String RESOURCE_ACCOUNT_GET_PREFIX = "/account/";
   public static final String RESOURCE_ACCOUNT_GET = RESOURCE_ACCOUNT_GET_PREFIX + "{accountId}";

   private Constants() throws IllegalAccessException {
       throw new IllegalAccessException("Utility class");
   }

}

特别值得注意的是 UNITY_ACCOUNT_ID 值。这代表了我们在这个例子中所操作的单一账户的ID,它的出现只是为了让Kafka能够将针对这个特定账户实体的消息进行关联。

AccountPrinter 类将周期性地输出银行账户的当前状态:

package com.example.esexample.util;

import com.example.esexample.service.BankAccountService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.server.ResponseStatusException;

@Component
@Slf4j
public class AccountPrinter {

   @Autowired
   private BankAccountService bankAccountService;

   @Scheduled(fixedRate = 5_000)
   public void printAccountState() {
       log.info("-----------------------------------");
       try {
           log.info("Account state: {}", bankAccountService.getAccount(Constants.UNITY_ACCOUNT_ID));
       } catch (RestClientException e) {
           log.warn(
                   "Account service still starting up (unable to parse response for account " + Constants.UNITY_ACCOUNT_ID + ")");
       } catch (ResponseStatusException e) {
           if (e.getStatus() == HttpStatus.SERVICE_UNAVAILABLE) {
               log.warn("Account [id: '{}'] not yet available", Constants.UNITY_ACCOUNT_ID);
           } else if (e.getStatus() == HttpStatus.NOT_FOUND) {
               log.warn("Account [id: '{}'] not found", Constants.UNITY_ACCOUNT_ID);
           } else {
               log.error("Failed to fetch account " + Constants.UNITY_ACCOUNT_ID, e);
           }
       } catch (RuntimeException e) {
           log.error("Failed to fetch account " + Constants.UNITY_ACCOUNT_ID, e);
       }
       log.info("-----------------------------------");
   }

}

服务层和数据层

AccountPrinter 需要一个BankAccountService 来获取最新的可用账户状态:

package com.example.esexample.service;

import com.example.esexample.dl.BankAccountRepository;
import com.example.esexample.model.entities.BankAccount;
import com.example.esexample.util.Constants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController()
@Slf4j
public class BankAccountService {

   @Autowired
   private BankAccountRepository bankAccountRepository;

   @RequestMapping(Constants.RESOURCE_ACCOUNT_GET)
   public BankAccount getAccount(@RequestParam(value = "accountId") String accountId) {
       if (null == accountId || accountId.isEmpty()) {
           throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No account ID specified");
       }

       try {
           final BankAccount account = bankAccountRepository.getAccount(accountId);
           if (account == null) {
               throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Specified account not found");
           }
           return account;
       } catch (IllegalStateException e) {
           throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
                   "This server is currently unable to process the request - please try again later");
       }
   }

}

该服务提供了一个REST端点来获取一个账户,给定其账户ID。它只是数据层存储库的一个传输包装,它是:

package com.example.esexample.dl;

import com.example.esexample.model.entities.BankAccount;
import com.example.esexample.util.Constants;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.common.serialization.StringSerializer;
import org.apache.kafka.streams.state.HostInfo;
import org.apache.kafka.streams.state.QueryableStoreTypes;
import org.apache.kafka.streams.state.ReadOnlyKeyValueStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.stream.binder.kafka.streams.InteractiveQueryService;
import org.springframework.stereotype.Repository;
import org.springframework.web.client.RestTemplate;

@Slf4j
@Repository
public class BankAccountRepository {

   @Autowired
   private InteractiveQueryService interactiveQueryService;

   public BankAccount getAccount(String accountId) {
       HostInfo hostInfo = interactiveQueryService
               .getHostInfo(Constants.EVENT_STORE_ACCOUNTS, accountId, new StringSerializer());

       if (null == hostInfo || null == hostInfo.host()) {
           throw new IllegalStateException("Unable to determine host responsible for the account");
       }

       if (interactiveQueryService.getCurrentHostInfo().equals(hostInfo)) {
           log.debug("Fetching account from local host {}:{}", hostInfo.host(), hostInfo.port());
           final ReadOnlyKeyValueStore<String, BankAccount> accountStore = interactiveQueryService
                   .getQueryableStore(Constants.EVENT_STORE_ACCOUNTS,
                           QueryableStoreTypes.<String, BankAccount>keyValueStore());

           return accountStore.get(accountId);
       } else {
           log.debug("Fetching account from remote host {}:{}", hostInfo.host(), hostInfo.port());

           RestTemplate restTemplate = new RestTemplate();
           return restTemplate.getForEntity(String.format("http://%s:%d%s%s", hostInfo.host(), hostInfo.port(),
                   Constants.RESOURCE_ACCOUNT_GET_PREFIX, accountId), BankAccount.class).getBody();
       }
   }

}

这个存储库使用Kafka的交互式查询服务来获得对KeyValueStore的访问,从中可以获取最新的衍生账户状态。如前所述,Kafka通过键来识别和关联数据,将一个给定的键的所有数据存储在本地给定的节点。存储库查询指定银行账户ID的HostInfo--如果Kafka回应说给定键的数据被存储在本地,存储库就可以请求一个可查询的键/值存储,通过它可以直接获取账户数据。

当扩大规模并在多个节点上运行服务和Kafka时,从交互式查询服务返回的HostInfo可能表明,给定键的数据被存储在一个外部节点上。在这种情况下,存储库作为一个代理,从确定的节点的REST服务(特别是其BankAccountService.getAccount() 方法)中获取账户数据。这种拓扑结构允许从任何REST节点请求任何数据,而数据层和Kafka将在幕后协调请求被路由到哪个特定的服务节点,这取决于Kafka在哪里为指定的消息关键字存储数据。

消息集成和处理层

现在我们已经有了完整的领域、数据和服务层,我们可以添加我们的消息处理节点。这些节点将向系统注入消息(作为模拟的用户输入),在拓扑结构中处理消息,以及作为Kafka的键/值存储的持久化汇。

输入

这是一个账户开放者,它将发送一个初始的ACCOUNT_OPENED 事件,以在Kafka的存储中实例化初始账户对象。

package com.example.esexample.pipeline.input;

import com.example.esexample.model.entities.BankAccount;
import com.example.esexample.model.events.AccountEvent;
import com.example.esexample.model.events.AccountEventType;
import com.example.esexample.service.BankAccountService;
import com.example.esexample.util.Constants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.server.ResponseStatusException;

import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;

@Slf4j
@Service
public class AccountOpener {

   @Autowired
   private BankAccountService bankAccountService;

   private AtomicBoolean accountOpened = new AtomicBoolean(false);

   @Bean
   public Supplier<Message<AccountEvent>> openAccount() {
       return () -> {
           final String accountId = Constants.UNITY_ACCOUNT_ID;

           try {
               bankAccountService.getAccount(accountId);
               return null;
           } catch (RestClientException e) {
               log.warn("Account service still starting up (unable to parse response for account " + accountId + ")");
               return null;
           } catch (ResponseStatusException e) {
               if (e.getStatus() != HttpStatus.NOT_FOUND) {
                   return null;
               }
           }

           if (!accountOpened.compareAndSet(false, true)) {
               return null;
           }
           log.info("Requesting opening of account '{}'", accountId);

           final BankAccount bankAccount = BankAccount.builder().accountId(accountId).balance(0l).build();

           final AccountEvent accountEvent = AccountEvent.builder().accountId(accountId)
                   .eventType(AccountEventType.ACCOUNT_OPENED).bankAccount(bankAccount).build();

           log.info("Sending account event: {}", accountEvent);

           return MessageBuilder.withPayload(accountEvent).setHeader("messageKey", accountId)
                   .setHeader(Constants.HEADER_ACCOUNT_ID, accountId)
                   .setHeader(Constants.HEADER_EVENT_TYPE, accountEvent.getEventType())
                   .setHeader(Constants.HEADER_CORRELATION_UUID, UUID.randomUUID().toString()).build();
       };
   }

}

这是一个命令请求器,它模拟了用户从账户中存钱和取钱的输入。

package com.example.esexample.pipeline.input;

import com.example.esexample.model.commands.Command;
import com.example.esexample.model.commands.CommandType;
import com.example.esexample.model.entities.BankAccount;
import com.example.esexample.service.BankAccountService;
import com.example.esexample.util.Constants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.server.ResponseStatusException;

import java.util.UUID;
import java.util.function.Supplier;

@Slf4j
@Service
public class CommandRequestor {

   @Autowired
   private BankAccountService bankAccountService;

   @Bean
   public Supplier<Message<Command>> depositSource() {
       return () -> {
           final String accountId = Constants.UNITY_ACCOUNT_ID;

           try {
               final BankAccount bankAccount = bankAccountService.getAccount(accountId);
               if (null == bankAccount) {
                   return null;
               }
           } catch (RestClientException e) {
               log.warn("Account service still starting up (unable to parse response for account " + accountId + ")");
               return null;
           } catch (ResponseStatusException e) {
               if (e.getStatus() != HttpStatus.NOT_FOUND && e.getStatus() != HttpStatus.SERVICE_UNAVAILABLE) {
                   return null;
               }
           }

           Long amount = Math.round(Math.random() * 9.0d + 1.0d);
           Command depositRequest = Command.builder().commandType(CommandType.DEPOSIT).accountId(accountId)
                   .amount(amount).build();

           log.info("Sending deposit request: {}", depositRequest);

           return MessageBuilder.withPayload(depositRequest).setHeader(Constants.HEADER_ACCOUNT_ID, accountId)
                   .setHeader(Constants.HEADER_CORRELATION_UUID, UUID.randomUUID().toString()).build();
       };
   }

   @Bean
   public Supplier<Message<Command>> withdrawalSource() {
       return () -> {
           final String accountId = Constants.UNITY_ACCOUNT_ID;

           try {
               final BankAccount bankAccount = bankAccountService.getAccount(accountId);
               if (null == bankAccount) {
                   return null;
               }
           } catch (RestClientException e) {
               log.warn("Account service still starting up (unable to parse response for account " + accountId + ")");
               return null;
           } catch (ResponseStatusException e) {
               if (e.getStatus() != HttpStatus.NOT_FOUND && e.getStatus() != HttpStatus.SERVICE_UNAVAILABLE) {
                   return null;
               }
           }

           Long amount = Math.round(Math.random() * 49.0d + 1.0d);
           Command withdrawRequest = Command.builder().commandType(CommandType.WITHDRAW).accountId(accountId)
                   .amount(amount).build();

           log.info("Sending withdrawal request: {}", withdrawRequest);

           return MessageBuilder.withPayload(withdrawRequest).setHeader(Constants.HEADER_ACCOUNT_ID, accountId)
                   .setHeader(Constants.HEADER_CORRELATION_UUID, UUID.randomUUID().toString()).build();
       };
   }

}

请求的取款金额是存款金额的五倍,允许在资金不足时发生一些失败。当最终运行完全工作的例子时,这些失败将在应用程序的日志输出中显示。

命令和事件处理

让我们更详细地看一下命令处理程序。它委托给命令的业务逻辑来检查它们是否可以在最新的可用账户状态下被处理。如果可以,处理就会发生并产生一个事件。如果处理不能发生。 **null**将被返回,并且不会产生任何事件--在我们的处理拓扑中,我们用 **null**来表示一个给定的消息流的终止。

package com.example.esexample.pipeline.processing;

import com.example.esexample.model.entities.BankAccount;
import com.example.esexample.model.events.AccountEvent;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.streams.kstream.KStream;
import org.apache.kafka.streams.kstream.KTable;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Service;

import java.util.function.BiFunction;

@Service
@Slf4j
public class EventHandler {

   @Bean
   public BiFunction<KStream<String, AccountEvent>, KTable<String, BankAccount>, KStream<String, AccountEvent>> processEvent() {
       return (eventStream, accountTable) -> eventStream
               .leftJoin(accountTable, (event, bankAccount) -> null == bankAccount ? event : null)
               .filter((s, event) -> {
                   if (null != event) {
                       log.info("Sending account event {}", event);
                   }
                   return null != event;
               });
   }

}

下面是存储汇,它在消息传递拓扑结构中充当终结者节点。这些只是记录它们收到的任何消息--将这些消息用于持久的键/值存储的真正魔力是通过Spring Cloud Stream Kafka配置完成的:

package com.example.esexample.pipeline.processing;

import com.example.esexample.model.commands.Command;
import com.example.esexample.model.entities.BankAccount;
import com.example.esexample.model.events.AccountEvent;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.streams.kstream.KStream;
import org.apache.kafka.streams.kstream.KTable;
import org.springframework.context.annotation.Bean;

import java.util.function.Consumer;
import java.util.function.Function;

@Slf4j
public class StorageSinks {

   @Bean
   public Function<KStream<String, AccountEvent>, KStream<String, BankAccount>> getAccountFromEvent() {
       return (eventStream) -> eventStream.mapValues((s, event) -> event.getBankAccount());
   }

   @Bean
   public Consumer<KTable<String, BankAccount>> accountStorageSink() {
       return accountTable -> {
           accountTable.mapValues((accountId, bankAccount) -> {
               log.info("Sinking account #{} to persistent state store: {} [{}]", accountId,
                       accountTable.queryableStoreName(), bankAccount);
               return bankAccount;
           });
       };
   }

   @Bean
   public Consumer<KTable<String, Command>> commandStorageSink() {
       return commandTable -> {
           commandTable.mapValues((accountId, command) -> {
               log.info("Sinking command to persistent state store: {} [{}]", commandTable.queryableStoreName(),
                       command);
               return command;
           });
       };
   }

}

将一切联系起来--应用配置

需要一个基本的Lombok配置文件来正确支持我们的JSON序列化。在你的src/main/java 文件夹的根部创建一个lombok.config 文件,并在其中添加以下内容。

config.stopBubbling = true
lombok.copyableAnnotations += com.fasterxml.jackson.annotation.JsonProperty

现在我们已经有了所有的代码,我们需要将每个单独的组件连接成一个完整的处理拓扑结构。这是通过一个application.yml 配置文件完成的。用下面的application.yml 替换你项目中的application.properties 文件:

spring.application.name: es-example

spring.cloud.stream:
 default.content-type: application/json
 kafka:
   streams.binder.configuration:
     processing.guarantee: exactly_once
###############
# SET THIS TO A VALID HOSTNAME BEFORE RUNNING THE APP!
###############
     application.server: CHANGEME.LOCAL.HOST:8080
     commit.interval.ms: 1000
   streams.binder.stateStoreRetry.maxAttempts: 5
   default.producer:
     messageKeyExpression: headers['accountId'].getBytes('UTF-8')
     sync: true
     configuration:
       acks: all
       retries: 1
       enable.idempotence: true
       max.block.ms: 5000
# Enable indefinite topic message retention
     topic.properties.retention:
       ms: -1
       bytes: -1
   default.consumer.partitioned: true
# Which spring cloud stream processing functions should be activated at runtime
 function.definition: openAccount;processEvent;getAccountFromEvent;accountStorageSink;commandStorageSink;depositSource;withdrawalSource;processCommand
 bindings:
   openAccount-out-0.destination: source.accounts
   processEvent-in-0.destination: source.accounts
   processEvent-in-1.destination: stores.accounts
   processEvent-out-0.destination: events
   depositSource-out-0.destination: commands
   withdrawalSource-out-0.destination: commands
   processCommand-in-0.destination: commands
   processCommand-in-1.destination: stores.accounts
   processCommand-out-0.destination: events
   getAccountFromEvent-in-0.destination: events
   getAccountFromEvent-out-0.destination: stores.accounts
   accountStorageSink-in-0.destination: stores.accounts
   commandStorageSink-in-0.destination: commands
 kafka.streams.bindings:
   accountStorageSink-in-0.consumer.materializedAs: stores.accounts
   getAccountFromEvent-in-0.consumer.materializedAs: stores.events
   commandStorageSink-in-0.consumer.materializedAs: stores.commands
# Each processor bean within a Kafka Streams topology requires a unique application ID
 kafka.streams.binder.functions:
   openAccount.applicationId: openAccount
   processEvent.applicationId: processEvent
   depositSource.applicationId: depositSource
   withdrawalSource.applicationId: depositSource
   processCommand.applicationId: processCommand
   getAccountFromEvent.applicationId: getAccountFromEvent
   accountStorageSink.applicationId: accountStorageSink
   commandStorageSink..applicationId: commandSink

确保你在运行应用程序之前为 **application.server**的值,然后再运行应用程序。这应该对应于你的本地主机名--kafka需要这个来支持其在多个集群节点之间的HostInfo路由支持。

配置中的主要兴趣点是。

spring.cloud.stream.function.definition

这表明在运行的应用程序中,哪些给定的Spring Cloud Function处理组件应该被启用。在这里,我们列出了到目前为止在这个例子中添加的所有功能。

spring.cloud.stream.bindings

这些配置了我们的消息传递拓扑结构--所有的输入、输出和中间处理功能是如何连接的。每个命名的目的地都对应着一个独特的Kafka主题,用于在处理功能之间路由消息。

spring.cloud.stream.kafka.streams.bindings

我们正在使用我们的琐碎的存储汇功能,作为Kafka内命名的键/值存储的标记;这些配置选项表明哪些存储应该被创建和使用。你可能还注意到,其中一些存储名称包含在 spring.cloud.stream.bindings- 正如文章开头提到的,Kafka的存储API充当了其消息传递主题之上的层。这意味着一个命名的键/值存储对应于一个消息主题,所以它也可以通过KStreamKTableAPIs在Kafka Streams消息处理拓扑中使用。我们在CommandHandler中使用这种机制,在试图处理一个命令时有效地加入最新的可用账户数据。这使我们能够通过查看最新存储的账户状态来快速检查命令是否可以被处理,而不需要重新处理每一个事件来确定当前的账户余额是多少。

spring.cloud.stream.kafka.streams.binder.functions

应用程序中的每个spring cloud功能都需要自己的应用程序ID,以便在Kafka处理拓扑中进行唯一的识别。在这个例子中,我们将所有的处理功能都包含在一个单一的应用程序中--然而,这些功能通常会被分离出来,成为他们自己独立的微服务。对于单个应用程序包含单个处理功能的情况,这个配置选项可以省略,在这种情况下,Spring将使用主 spring.application.name 配置属性来识别Kafka拓扑结构中的处理功能。

系统回顾

现在一切就绪,我们可以退一步,回顾一下刚刚创建的系统。下图说明了命令和查询是如何流转的,以及Kafka是如何在途中被利用的。

每个事件都流经一系列独立的功能--命令处理程序、事件处理程序和存储槽--直到产生的实体被持久化,标志着事件对系统的修改结束。

每个查询都要简单得多,因为只需要当前的系统状态,我们已经方便地将其与相应的事件一起持久化。查询请求流经一个传统的服务和存储库实现,最终利用Kafka的交互式查询API来产生最新的系统状态。

总结

本文展示了一个完整的基于Kafka的事件源系统的工作应用,实现了我们简单的银行账户例子。它还分享了几个对现实世界的事件源应用有用的效率范式,特别是派生状态存储,它提供了对最新可用状态的轻松访问,而不需要为每一个处理操作重新处理("源")所有的存储事件。

一旦你建立并运行完成的应用程序,你将看到日志输出,表明命令和事件的发送和接收,以及最新账户余额的定期输出。

值得一提的是,这个例子决不是典型的例子--它只是展示了设计和构建这样一个系统的一种方式。希望看到这样一个完全有效的例子可以为实现你自己的事件源系统提供灵感。