FlinkとKafkaを使ってデータパイプラインを構築する

1概要

Apache Flink は、Javaで簡単に使用できるストリーム処理フレームワークです。 Apache Kafka は、高いフォールトトレランスをサポートする分散ストリーム処理システムです。

このチュートリアルでは、これら2つのテクノロジを使用してデータパイプラインを構築する方法について説明します。

2インストール

Apache Kafkaをインストールして設定するには、https://kafka.apache.org/quickstart[公式ガイド]を参照してください。インストールが完了したら、次のコマンドを使用して flink input および flink__outputという新しいトピックを作成できます。

 bin/kafka-topics.sh --create \
  --zookeeper localhost:2181 \
  --replication-factor 1 --partitions 1 \
  --topic flink__output

 bin/kafka-topics.sh --create \
  --zookeeper localhost:2181 \
  --replication-factor 1 --partitions 1 \
  --topic flink__input

このチュートリアルでは、Apache Kafkaのデフォルト設定とデフォルトポートを使用します。

3 Flinkの使い方

Apache Flinkはリアルタイムのストリーム処理技術を可能にします。 ** このフレームワークでは、ストリームのソースまたはシンクとして複数のサードパーティシステムを使用することができます。

Flinkに - 利用可能なさまざまなコネクタがあります。

  • Apache Kafka(ソース/シンク)

  • Apache Cassandra(シンク)

  • Amazon Kinesis Streams(ソース/シンク)

  • Elasticsearch(シンク)

  • Hadoopファイルシステム(シンク)

  • RabbitMQ(ソース/シンク)

  • Apache NiFi(ソース/シンク)

  • TwitterストリーミングAPI(ソース)

プロジェクトにFlinkを追加するには、以下のMaven依存関係を含める必要があります。

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-core</artifactId>
    <version>1.5.0</version>
</dependency>
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-connector-kafka-0.11__2.11</artifactId>
    <version>1.5.0</version>
</dependency>

これらの依存関係を追加することで、Kafkaトピックとの間でやり取りできるようになります。 Flinkの最新版はhttps://search.maven.org/search?q=g:org.apache.flink[Maven Central]にあります。

4カフカストリングコンシューマ

  • FlinkでKafkaのデータを使用するには、トピックとKafkaのアドレスを指定する必要があります** オフセットを保持するために使用されるグループIDも指定する必要があるため、常に最初からデータ全体を読み取ることはできません。

__FlinkKafkaConsumer __easierを作成する静的メソッドを作成しましょう。

public static FlinkKafkaConsumer011<String> createStringConsumerForTopic(
  String topic, String kafkaAddress, String kafkaGroup ) {

    Properties props = new Properties();
    props.setProperty("bootstrap.servers", kafkaAddress);
    props.setProperty("group.id",kafkaGroup);
    FlinkKafkaConsumer011<String> consumer = new FlinkKafkaConsumer011<>(
      topic, new SimpleStringSchema(), props);

    return consumer;
}

このメソッドは topic、kafkaAddress、 、および kafkaGroup を取り、データのデコードに SimpleStringSchema を使用しているため、指定されたトピックのデータを String として使用する FlinkKafkaConsumer を作成します。

クラス名の中の数字 011 は、Kafkaのバージョンを表します。

5カフカストリングプロデューサー

  • Kafkaにデータを生成するには、使用したいKafkaのアドレスとトピックを指定する必要があります** ここでも、さまざまなトピックのプロデューサを作成するのに役立つ静的メソッドを作成できます。

public static FlinkKafkaProducer011<String> createStringProducer(
  String topic, String kafkaAddress){

    return new FlinkKafkaProducer011<>(kafkaAddress,
      topic, new SimpleStringSchema());
}

Kafkaトピックを生成するときにグループIDを提供する必要がないので、このメソッドは topic kafkaAddress のみを引数として取ります。

6. 文字列ストリーム処理

完全に機能する消費者と生産者がいる場合、Kafkaからのデータを処理してからKafkaに結果を保存することを試みることができます。ストリーム処理に使用できる関数の完全なリストはhttps://www.baeldung.com/apache-flink[ここ]にあります。

この例では、各Kafkaエントリの単語を大文字にしてからKafkaに書き戻します。

このために、カスタムの MapFunction を作成する必要があります。

public class WordsCapitalizer implements MapFunction<String, String> {
    @Override
    public String map(String s) {
        return s.toUpperCase();
    }
}

関数を作成したら、それをストリーム処理で使用できます。

public static void capitalize() {
    String inputTopic = "flink__input";
    String outputTopic = "flink__output";
    String consumerGroup = "baeldung";
    String address = "localhost:9092";
    StreamExecutionEnvironment environment = StreamExecutionEnvironment
      .getExecutionEnvironment();
    FlinkKafkaConsumer011<String> flinkKafkaConsumer = createStringConsumerForTopic(
      inputTopic, address, consumerGroup);
    DataStream<String> stringInputStream = environment
      .addSource(flinkKafkaConsumer);

    FlinkKafkaProducer011<String> flinkKafkaProducer = createStringProducer(
      outputTopic, address);

    stringInputStream
      .map(new WordsCapitalizer())
      .addSink(flinkKafkaProducer);
}
  • アプリケーションは flink input トピックからデータを読み取り、ストリームに対して操作を実行してから、結果をKafkaの flink output ____topicに保存します。

FlinkとKafkaを使って文字列を処理する方法を見ました。しかし、多くの場合、カスタムオブジェクトに対して操作を実行する必要があります。次の章でこれを行う方法を見ていきます。

7. カスタムオブジェクトの逆シリアル化

次のクラスは、送信者と受信者に関する情報を含む単純なメッセージを表します。

@JsonSerialize
public class InputMessage {
    String sender;
    String recipient;
    LocalDateTime sentAt;
    String message;
}

以前は SimpleStringSchema を使用してKafkaからのメッセージをデシリアライズしていましたが、現在は データを直接カスタムオブジェクトにデシリアライズしたい

これを行うには、カスタム__DeserializationSchemaが必要です。

public class InputMessageDeserializationSchema implements
  DeserializationSchema<InputMessage> {

    static ObjectMapper objectMapper = new ObjectMapper()
      .registerModule(new JavaTimeModule());

    @Override
    public InputMessage deserialize(byte[]bytes) throws IOException {
        return objectMapper.readValue(bytes, InputMessage.class);
    }

    @Override
    public boolean isEndOfStream(InputMessage inputMessage) {
        return false;
    }

    @Override
    public TypeInformation&lt;InputMessage&gt; getProducedType() {
        return TypeInformation.of(InputMessage.class);
    }
}

ここでは、メッセージがKafkaでJSONとして保持されていると想定しています。

LocalDateTime 型のフィールドがあるため、 __JavaTimeModuleを指定する必要があります。これは、 LocalDateTime__オブジェクトをJSONにマッピングする処理を行います。

  • Flinkスキーマは、すべての演算子(スキーマや関数など)がジョブの開始時にシリアル化されるため、シリアル化できないフィールドを持つことはできません

Apache Sparkにも同様の問題があります。この問題に対する既知の修正の1つは、上記のObjectMapperで行ったように、フィールドを static として初期化することです。それは最もきれいな解決策ではありませんが、それは比較的単純で、仕事をします。

isEndOfStream メソッドは、特定のデータを受信するまでstreamを処理しなければならないという特別な場合に使用できます。しかし、私たちの場合は必要ありません。

8カスタムオブジェクト直列化

それでは、私たちのシステムにメッセージのバックアップを作成する可能性を持たせたいとしましょう。このプロセスを自動化し、各バックアップは1日中に送信されたメッセージで構成する必要があります。

また、バックアップメッセージには一意のIDを割り当てる必要があります。

この目的のために、以下のクラスを作成することができます。

public class Backup {
    @JsonProperty("inputMessages")
    List<InputMessage> inputMessages;
    @JsonProperty("backupTimestamp")
    LocalDateTime backupTimestamp;
    @JsonProperty("uuid")
    UUID uuid;

    public Backup(List<InputMessage> inputMessages,
      LocalDateTime backupTimestamp) {
        this.inputMessages = inputMessages;
        this.backupTimestamp = backupTimestamp;
        this.uuid = UUID.randomUUID();
    }
}

UUIDの生成メカニズムは重複する可能性があるため、完全ではありません。ただし、これはこの例の範囲にとって十分です。

Backup オブジェクトをJSONとしてKafkaに保存したいので、 SerializationSchema を作成する必要があります。

public class BackupSerializationSchema
  implements SerializationSchema<Backup> {

    ObjectMapper objectMapper;
    Logger logger = LoggerFactory.getLogger(BackupSerializationSchema.class);

    @Override
    public byte[]serialize(Backup backupMessage) {
        if(objectMapper == null) {
            objectMapper = new ObjectMapper()
              .registerModule(new JavaTimeModule());
        }
        try {
            return objectMapper.writeValueAsString(backupMessage).getBytes();
        } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
            logger.error("Failed to parse JSON", e);
        }
        return new byte[0];
    }
}

9タイムスタンプメッセージ

毎日のすべてのメッセージのバックアップを作成したいので、メッセージにはタイムスタンプが必要です。

Flinkは3つの異なる時間特性 __EventTime、ProcessingTime、 および IngestionTimeを提供します

今回の場合、メッセージが送信された時刻を使用する必要があるため、__EventTimeを使用します。

EventTime ** を使用するには、入力データからタイムスタンプを抽出する TimestampAssigner が必要です。

public class InputMessageTimestampAssigner
  implements AssignerWithPunctuatedWatermarks<InputMessage> {

    @Override
    public long extractTimestamp(InputMessage element,
      long previousElementTimestamp) {
        ZoneId zoneId = ZoneId.systemDefault();
        return element.getSentAt().atZone(zoneId).toEpochSecond() **  1000;
    }

    @Nullable
    @Override
    public Watermark checkAndGetNextWatermark(InputMessage lastElement,
      long extractedTimestamp) {
        return new Watermark(extractedTimestamp - 1500);
    }
}

これはFlinkが期待するフォーマットなので、 LocalDateTime EpochSecond に変換する必要があります。タイムスタンプを割り当てた後、すべての時間ベースの操作は sentAt フィールドからの時間を操作に使用します。

Flinkはタイムスタンプがミリ秒単位であることを期待しており、 toEpochSecond() は1000倍するために必要な時間を秒単位で返すので、Flinkは正しくウィンドウを作成します。

Flinkは _Watermarkの概念を定義しています。 _ 透かしは、送信された順序でデータが届かない場合に便利です。

透かしよりも低いタイムスタンプを持つ要素はまったく処理されません。

10タイムウィンドウの作成

バックアップで1日に送信されたメッセージのみを確実に収集するために、ストリーム上で timeWindowAll メソッドを使用できます。これはメッセージをウィンドウに分割します。

ただし、各ウィンドウからのメッセージを集計して Backup として返す必要があります。

これを行うには、カスタム AggregateFunction が必要です。

public class BackupAggregator
  implements AggregateFunction<InputMessage, List<InputMessage>, Backup> {

    @Override
    public List<InputMessage> createAccumulator() {
        return new ArrayList<>();
    }

    @Override
    public List<InputMessage> add(
      InputMessage inputMessage,
      List<InputMessage> inputMessages) {
        inputMessages.add(inputMessage);
        return inputMessages;
    }

    @Override
    public Backup getResult(List<InputMessage> inputMessages) {
        return new Backup(inputMessages, LocalDateTime.now());
    }

    @Override
    public List<InputMessage> merge(List<InputMessage> inputMessages,
      List<InputMessage> acc1) {
        inputMessages.addAll(acc1);
        return inputMessages;
    }
}

11バックアップの集約

適切なタイムスタンプを割り当て、 AggregateFunction を実装した後、ついにKafka入力を取得して処理することができます。

public static void createBackup () throws Exception {
    String inputTopic = "flink__input";
    String outputTopic = "flink__output";
    String consumerGroup = "baeldung";
    String kafkaAddress = "192.168.99.100:9092";
    StreamExecutionEnvironment environment
      = StreamExecutionEnvironment.getExecutionEnvironment();
    environment.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
    FlinkKafkaConsumer011<InputMessage> flinkKafkaConsumer
      = createInputMessageConsumer(inputTopic, kafkaAddress, consumerGroup);
    flinkKafkaConsumer.setStartFromEarliest();

    flinkKafkaConsumer.assignTimestampsAndWatermarks(
      new InputMessageTimestampAssigner());
    FlinkKafkaProducer011<Backup> flinkKafkaProducer
      = createBackupProducer(outputTopic, kafkaAddress);

    DataStream<InputMessage> inputMessagesStream
      = environment.addSource(flinkKafkaConsumer);

    inputMessagesStream
      .timeWindowAll(Time.hours(24))
      .aggregate(new BackupAggregator())
      .addSink(flinkKafkaProducer);

    environment.execute();
}

12. 結論

この記事では、Apache FlinkとApache Kafkaを使って簡単なデータパイプラインを作成する方法を説明しました。

いつものように、このコードはhttps://github.com/eugenp/tutorials/tree/master/libraries-data[Githubに追加]を見つけることができます。