Spring-Kafka コンシューマの動的開始および停止メソッドに基づくマルチ環境スイッチングの実装
1. 背景
運用環境には、正式な運用環境 (A)、バックアップ運用環境 (B)、および運用前環境 (P) の 3 つのセットがあります。これら 3 つの環境のコードはまったく同じですが、3 セットのプロセスがデプロイされます (各環境は複数のノードをデプロイする可能性があるため、3 つの「セット」ではなく 3 つの「セット」) ですが、データベースとミドルウェアは共有されます。したがって、これら 3 つの環境は同じ Kafka クラスターを共有します。
各機能の開発ライフサイクルは、本番環境への「オンライン化」を伴いますが、本番環境もシーケンシャルであり、3 つの環境 A、B、P に同時にデプロイされるのではなく、それぞれにデプロイされます。最初に P 環境とテストパートナーが P 環境のコードを検証します (この時点では主に、新しく開発した機能が本番データベースのデータでテストに合格できるかどうかを検証します。その後、プロダクト マネージャーも承認します)この環境では機能します)。
どうしたの?一部の企業は、P 環境で Kafka の消費後のロジックを検証する必要があります。ただし、Kafka には消費者の選択に関する独自の内部ルールがあり、A、B、および P を同時に消費することはできません。現在の Kafka コンシューマはどうですか? として環境を指定します。この記事ではこの質問に答えます。
2. コアステップ 1 (Spring-Kafka コントロール)
2.1 まず、Spring の依存関係注入関数を使用して、Spring で Kafka のさまざまな機能を管理できる KafkaListenerEndpointRegistry のインスタンスを取得する必要があります。
@Autowired
private KafkaListenerEndpointRegistry kafkaListenerEndpointRegistry;
2.2kafkaListenerEndpointRegistry
以下からすべての ListenerContainer を取得します。
Collection<MessageListenerContainer> listenerContainers =
kafkaListenerEndpointRegistry.getListenerContainers();
2.3 すべての「リスナー コンテナ」を走査し、「start」または「stop」メソッドを呼び出します。
for (MessageListenerContainer listenerContainer : listenerContainers) {
listenerContainer.start();
listenerContainer.stop();
}
上記は Kafka を制御するためのコア コードであり、Kafka を制御するための開始および停止ロジックを使用すると、必要に応じていつでも Kafka コンシューマーを動的に開始および停止できます。
2.4 MessageListenerContainerの継承図
MessageListenerContainer
の継承図からわかるように、このインターフェイスはSmartLifecyclie
とLifecycle
を継承しますPhased
。このトピックに関連するメソッド定義がいくつかあります。
start()
: 「リスナーコンテナ」の起動は、サスペンドからの再開を意味するresume()
「リスナーコンテナ」とは異なり、Kafkaのブローカーへの接続を確立することを意味します。コンテナを起動せずに一時停止および再開することはできません。resume()
start()
stop()
: 「リスナーコンテナ」を停止します。この操作は、spring-kafka が Kafka のブローカーからシャットダウンコマンドを送信し、Kafka のサービスとの接続を維持しなくなることです。getContainerProperties()
: 「リスナーコンテナ」の設定と監視対象トピックを取得できます。
以下に環境にバインドする方法を紹介します。
3 コアステップ2(環境の取得と判断)
A、B、P の 3 つの環境のコードは同じですが、A、B、P のいずれであるかは、コード自体の内部ロジックからは取得できません。コード以外の場所からのみ取得できます。環境情報はJVMパラメータから渡されましたが、サービス起動パラメータが規格外で不整合となるため、運用保守側が拒否しました。それで、違いは何ですか?沢山あります!最初に思い浮かぶのは、これら 3 つの環境のマシンと IP アドレスが異なるということです。それでは、そうしてみましょう。
3.1 マシン名の取得
マシン名を取得するコードは、開発マシン、テストマシン、本番マシンで実行する必要がある場合があり、テストマシンと本番マシンはどちらも一般的な Linux ですが、開発マシンは個人のローカルマシンであり、人によっては Mac OS システム、 Windows システムの場合、Linux システムで直接開発する人はほとんどいません。したがって、マシン名の取得にはさまざまなオペレーティング システムとの互換性が必要ですが、Java ではマシン名を取得するための汎用的な方法が見つからないため、代わりにコマンド ラインを使用してローカル コマンドを実行してマシン名を取得しています。
Process process = Runtime.getRuntime().exec("hostname");
if (null != process) {
hostname = StreamUtil.fetchAllDataAsString(process.getInputStream());
System.out.println("hostname = " + hostname);
process.destroy();
}
ローカル マシン名を取得した後、現在使用するマシンの名前を取得できれば、現在のマシンがコンシューマの起動を許可されているかどうかを判断できます。コンシューマを起動する (たとえば、zookeeper を使用するか、msyql を使用するなど)。
3.2 マシン名の決定
これは構成の取得方法の紹介ではなく、構成を取得した後の判断ロジックのデモンストレーションです。
private boolean isNeedConsume(String fromSwitch) {
// 配置可能有多个,按照逗号分割
String[] split = fromSwitch.split("\\s*,\\s*");
// 先判断主机名,再判断IP
String hostname = SystemCommandUtil.getHostname();
if (SizeUtil.isNotEmpty(hostname) && Arrays.stream(split).filter(t -> Objects.equals(hostname, t)).findAny().isPresent()) {
return true;
} else {
List<String> localIpAddress = LocalIpUtil.getLocalIpAddress();
return SizeUtil.isNotEmpty(localIpAddress) && Arrays.stream(split).filter(t -> localIpAddress.contains(t)).findAny().isPresent();
}
}
4.タイマー
プログラムは管理者による設定変更に随時対応する必要があるため、管理者が設定した設定情報を定期的に取得する必要があります。実装を簡素化するために、ここではタイマーを使用して取得します。コードは次のとおりです。
public class KafkaConsumerStartStopConfiguration implements ApplicationRunner {
@Resource
private ScheduledThreadPoolExecutor scheduledThreadPoolExecutor;
@Override
public void run(ApplicationArguments args) throws Exception {
int periodInSec = 60;
log.info("定时检查是否需要本节点启动Kafka消费者: '{}'sec", periodInSec);
scheduledThreadPoolExecutor.scheduleAtFixedRate(this::check, 0, periodInSec, TimeUnit.SECONDS);
}
}
5. 完全な例
メイン コード ロジックの順次バージョンを以下に示します。
@Slf4j
@Configuration
public class KafkaConsumerStartStopConfiguration implements ApplicationRunner {
@Autowired
private KafkaListenerEndpointRegistry kafkaListenerEndpointRegistry;
@Resource
private ScheduledThreadPoolExecutor scheduledThreadPoolExecutor;
/** 缓存上一次允许消费的主机名 **/
private String lastKafkaConsumeHostname;
// 等到Spring容器启动完毕后再执行的方法
@Override
public void run(ApplicationArguments args) throws Exception {
int periodInSec = 60;
log.info("定时检查是否需要本节点启动Kafka消费者: '{}'sec", periodInSec);
scheduledThreadPoolExecutor.scheduleAtFixedRate(this::check, 0, periodInSec, TimeUnit.SECONDS);
}
public void check() {
// 检查配置是否有变化?
// 检查当前节点是否有消费权利?
// 如果没有,则调用消费者的stop()方法
// 如果有,则调用消费者的start()方法
String fromSwitch = ""; // FIXME 此处为获取配置,示例代码省略
if (SizeUtil.isEmpty(fromSwitch)) {
log.error("Kafka消费的主机名配置无效: '{}'", fromSwitch);
return;
}
if (Objects.equals(lastKafkaConsumeHostname, fromSwitch) == false) {
log.info("Kafka允许消费的主机名配置有变化: '{}' -> '{}'", lastKafkaConsumeHostname, fromSwitch);
if (isNeedConsume(fromSwitch)) {
start(null);
} else {
stop(null);
}
lastKafkaConsumeHostname = fromSwitch;
} else {
log.debug("Kafka允许消费的主机名配置无变化: ('{}', '{}')", lastKafkaConsumeHostname, fromSwitch);
}
}
private boolean isNeedConsume(String fromSwitch) {
// 配置可能有多个,按照逗号分割
String[] split = fromSwitch.split("\\s*,\\s*");
// 先判断主机名,再判断IP
String hostname = SystemCommandUtil.getHostname();
if (SizeUtil.isNotEmpty(hostname) && Arrays.stream(split).filter(t -> Objects.equals(hostname, t)).findAny().isPresent()) {
return true;
} else {
List<String> localIpAddress = LocalIpUtil.getLocalIpAddress();
return SizeUtil.isNotEmpty(localIpAddress) && Arrays.stream(split).filter(t -> localIpAddress.contains(t)).findAny().isPresent();
}
}
public void start(List<String> targetTopicList) {
startOrStop(targetTopicList, true);
}
public void stop(List<String> targetTopicList) {
startOrStop(targetTopicList, false);
}
/**
* 方法描述: 启动或停止消费者<br/>
*
* @param targetTopicList 目标的主题列表,如果为null,则表示全部(当前进程所持有)的主题
* @param start true表示是执行启动消费者的操作,false表示停止消费者的操作
*/
private void startOrStop(List<String> targetTopicList, boolean start) {
log.info("启动或停止Kafka消费者, start: '{}', targetTopicList: '{}'", start, targetTopicList);
Collection<MessageListenerContainer> listenerContainers = kafkaListenerEndpointRegistry.getListenerContainers();
if (SizeUtil.isNotEmpty(listenerContainers)) {
for (MessageListenerContainer listenerContainer : listenerContainers) {
// 是否执行:默认是全部执行
boolean doit = true;
if (SizeUtil.isNotEmpty(targetTopicList)) {
// 当有指定主题,就需要判断特定主题才执行
String[] registerTopics = listenerContainer.getContainerProperties().getTopics();
if (SizeUtil.isNotEmpty(registerTopics)) {
doit = CollectionUtils.containsAny(targetTopicList, Arrays.asList(registerTopics));
}
}
if (doit) {
if (start) {
listenerContainer.start();
log.info("启动Kafka的topic: '{}'", Arrays.toString(listenerContainer.getContainerProperties().getTopics()));
} else {
listenerContainer.stop();
log.info("停止Kafka的topic: '{}'", Arrays.toString(listenerContainer.getContainerProperties().getTopics()));
}
}
}
}
}
}
上記のコードは多くのツール クラスを使用しており、これらのツール クラスでは内部ロジックの実装が必要になる場合があるため、コピーして使用することはできません。
また、このコードは複雑な設計と粗雑な実装の特性も反映しており、コードの設計時にstartOrStop(..)
パラメータを使用したいと考えているためですが、客観的な理由により、実際にはパラメータが使用されていないことがメソッドからわかります。targetTopicList
テーマを制御する必要はありませんが、この粒度により、全体として消費者がシャットダウンされます。
ご質問がございましたら、ご相談ください。記事が不適切な場合は、修正していただけます。