Erstellen einer Datenpipeline mit Flink und Kafka

1. Überblick

Apache Flink ist ein Stream-Verarbeitungs-Framework, das problemlos mit Java verwendet werden kann. Apache Kafka ist ein verteiltes Stream-Verarbeitungssystem, das eine hohe Fehlertoleranz unterstützt.

In diesem Tutorial sehen wir uns an, wie man mit diesen beiden Technologien eine Datenpipeline erstellt.

2. Installation

Informationen zum Installieren und Konfigurieren von Apache Kafka finden Sie unterofficial guide. Nach der Installation können wir die folgenden Befehle verwenden, um die neuen Themenflink_input undflink_output: zu erstellen

 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

Für dieses Tutorial verwenden wir die Standardkonfiguration und die Standardports für Apache Kafka.

Apache Flink ermöglicht eine Echtzeit-Stream-Verarbeitungstechnologie. The framework allows using multiple third-party systems as stream sources or sinks.

In Flink stehen verschiedene Anschlüsse zur Verfügung:

  • Apache Kafka (Quelle / Senke)

  • Apache Cassandra (Waschbecken)

  • Amazon Kinesis Streams (Quelle / Senke)

  • Elasticsearch (Spüle)

  • Hadoop FileSystem (Waschbecken)

  • RabbitMQ (Quelle / Senke)

  • Apache NiFi (Quelle / Senke)

  • Twitter Streaming API (Quelle)

Um Flink zu unserem Projekt hinzuzufügen, müssen wir die folgenden Maven-Abhängigkeiten einbeziehen:


    org.apache.flink
    flink-core
    1.5.0


    org.apache.flink
    flink-connector-kafka-0.11_2.11
    1.5.0

Durch Hinzufügen dieser Abhängigkeiten können wir Kafka-Themen konsumieren und daraus produzieren. Sie finden die aktuelle Version von Flink unterMaven Central.

4. Kafka String Consumer

To consume data from Kafka with Flink we need to provide a topic and a Kafka address. Wir sollten auch eine Gruppen-ID angeben, die zum Halten von Offsets verwendet wird, damit wir nicht immer die gesamten Daten von Anfang an lesen.

Erstellen wir eine statische Methode, die die Erstellung vonFlinkKafkaConsumer einfacher macht:

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

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

    return consumer;
}

Diese Methode verwendettopic, kafkaAddress, undkafkaGroup und erstellt dieFlinkKafkaConsumer, die Daten aus einem bestimmten Thema verbrauchen, alsString, da wirSimpleStringSchema zum Decodieren von Daten verwendet haben.

Die Zahl011 im Namen der Klasse bezieht sich auf die Kafka-Version.

5. Kafka String Produzent

To produce data to Kafka, we need to provide Kafka address and topic that we want to use. Auch hier können wir eine statische Methode erstellen, mit der wir Produzenten für verschiedene Themen erstellen können:

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

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

Diese Methode verwendet nurtopic undkafkaAddress als Argumente, da bei der Erstellung des Kafka-Themas keine Gruppen-ID angegeben werden muss.

6. String-Stream-Verarbeitung

Wenn wir einen voll funktionsfähigen Konsumenten und Produzenten haben, können wir versuchen, Daten von Kafka zu verarbeiten und dann unsere Ergebnisse wieder in Kafka zu speichern. Die vollständige Liste der Funktionen, die für die Stream-Verarbeitung verwendet werden können, finden Sie inhere.

In diesem Beispiel werden wir Wörter in jedem Kafka-Eintrag groß schreiben und sie dann an Kafka zurückschreiben.

Zu diesem Zweck müssen wir ein benutzerdefiniertesMapFunction erstellen:

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

Nach dem Erstellen der Funktion können wir sie in der Stream-Verarbeitung verwenden:

public static void capitalize() {
    String inputTopic = "flink_input";
    String outputTopic = "flink_output";
    String consumerGroup = "example";
    String address = "localhost:9092";
    StreamExecutionEnvironment environment = StreamExecutionEnvironment
      .getExecutionEnvironment();
    FlinkKafkaConsumer011 flinkKafkaConsumer = createStringConsumerForTopic(
      inputTopic, address, consumerGroup);
    DataStream stringInputStream = environment
      .addSource(flinkKafkaConsumer);

    FlinkKafkaProducer011 flinkKafkaProducer = createStringProducer(
      outputTopic, address);

    stringInputStream
      .map(new WordsCapitalizer())
      .addSink(flinkKafkaProducer);
}

Die Anwendung liest Daten aus dem Themaflink_input, führt Vorgänge für den Stream aus und speichert die Ergebnisse dann auf demflink_output -Stopp in Kafka.

Wir haben gesehen, wie man mit Flink und Kafka mit Strings umgeht. Oft ist es jedoch erforderlich, Vorgänge für benutzerdefinierte Objekte auszuführen. Wie das geht, erfahren Sie in den nächsten Kapiteln.

7. Deserialisierung von benutzerdefinierten Objekten

Die folgende Klasse stellt eine einfache Nachricht mit Informationen zu Absender und Empfänger dar:

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

Früher haben wirSimpleStringSchema verwendet, um Nachrichten von Kafka zu deserialisieren, jetzt jedochwe want to deserialize data directly to custom objects.

Dazu benötigen wir ein benutzerdefiniertesDeserializationSchema:

public class InputMessageDeserializationSchema implements
  DeserializationSchema {

    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 getProducedType() {
        return TypeInformation.of(InputMessage.class);
    }
}

Wir gehen hier davon aus, dass die Nachrichten in Kafka als JSON gespeichert sind.

Da wir ein Feld vom TypLocalDateTime haben, müssen wir dasJavaTimeModule, angeben, das die Zuordnung vonLocalDateTime Objekten zu JSON übernimmt.

Flink schemas can’t have fields that aren’t serializable, da alle Operatoren (wie Schemas oder Funktionen) zu Beginn des Jobs serialisiert werden.

Es gibt ähnliche Probleme in Apache Spark. Eine der bekannten Korrekturen für dieses Problem ist das Initialisieren von Feldern alsstatic, wie wir es mitObjectMapper oben getan haben. Es ist nicht die schönste Lösung, aber es ist relativ einfach und erledigt den Job.

Die MethodeisEndOfStream kann für den Sonderfall verwendet werden, in dem Streams nur verarbeitet werden sollen, bis bestimmte Daten empfangen werden. In unserem Fall wird dies jedoch nicht benötigt.

8. Benutzerdefinierte Objektserialisierung

Nehmen wir nun an, wir möchten, dass unser System die Möglichkeit hat, eine Sicherung von Nachrichten zu erstellen. Wir möchten, dass der Prozess automatisch abläuft und jede Sicherung aus Nachrichten besteht, die an einem ganzen Tag gesendet werden.

Außerdem sollte einer Sicherungsnachricht eine eindeutige ID zugewiesen werden.

Zu diesem Zweck können wir die folgende Klasse erstellen:

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

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

Bitte beachten Sie, dass der UUID-Generierungsmechanismus nicht perfekt ist, da er Duplikate zulässt. Dies reicht jedoch für den Umfang dieses Beispiels aus.

Wir möchten unserBackup-Objekt als JSON in Kafka speichern, daher müssen wir unsereSerializationSchema erstellen:

public class BackupSerializationSchema
  implements SerializationSchema {

    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. Zeitstempel-Nachrichten

Da wir für alle Nachrichten eines Tages ein Backup erstellen möchten, benötigen Nachrichten einen Zeitstempel.

Flink bietet die drei verschiedenen ZeitmerkmaleEventTime, ProcessingTime, andIngestionTime.

In unserem Fall müssen wir den Zeitpunkt verwenden, zu dem die Nachricht gesendet wurde, daher verwenden wirEventTime.

So verwenden SieEventTimewe need a TimestampAssigner which will extract timestamps from our input data:

public class InputMessageTimestampAssigner
  implements AssignerWithPunctuatedWatermarks {

    @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);
    }
}

Wir müssen unsereLocalDateTime inEpochSecond umwandeln, da dies das von Flink erwartete Format ist. Nach dem Zuweisen von Zeitstempeln verwenden alle zeitbasierten Vorgänge die Zeit aus dem FeldsentAtfür den Betrieb.

Da Flink erwartet, dass die Zeitstempel in Millisekunden liegen undtoEpochSecond() die Zeit in Sekunden zurückgibt, mussten wir sie mit 1000 multiplizieren, damit Flink die Fenster korrekt erstellt.

Flink definiert das Konzept vonWatermark. Watermarks are useful in case of data that don’t arrive in the order they were sent. Ein Wasserzeichen definiert die maximale Verspätung, die für die Verarbeitung von Elementen zulässig ist.

Elemente, deren Zeitstempel unter dem Wasserzeichen liegen, werden überhaupt nicht verarbeitet.

10. Zeitfenster erstellen

Um sicherzustellen, dass in unserer Sicherung nur Nachrichten erfasst werden, die an einem Tag gesendet wurden, können wir die MethodetimeWindowAll im Stream verwenden, mit der Nachrichten in Fenster aufgeteilt werden.

Wir müssen jedoch weiterhin Nachrichten aus jedem Fenster aggregieren und alsBackup zurückgeben.

Dazu benötigen wir ein benutzerdefiniertesAggregateFunction:

public class BackupAggregator
  implements AggregateFunction, Backup> {

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

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

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

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

11. Aggregieren von Backups

Nachdem wir die richtigen Zeitstempel zugewiesen und unsereAggregateFunction implementiert haben, können wir endlich unsere Kafka-Eingabe nehmen und verarbeiten:

public static void createBackup () throws Exception {
    String inputTopic = "flink_input";
    String outputTopic = "flink_output";
    String consumerGroup = "example";
    String kafkaAddress = "192.168.99.100:9092";
    StreamExecutionEnvironment environment
      = StreamExecutionEnvironment.getExecutionEnvironment();
    environment.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
    FlinkKafkaConsumer011 flinkKafkaConsumer
      = createInputMessageConsumer(inputTopic, kafkaAddress, consumerGroup);
    flinkKafkaConsumer.setStartFromEarliest();

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

    DataStream inputMessagesStream
      = environment.addSource(flinkKafkaConsumer);

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

    environment.execute();
}

12. Fazit

In diesem Artikel haben wir vorgestellt, wie Sie mit Apache Flink und Apache Kafka eine einfache Datenpipeline erstellen.

Wie immer kann der Codeover on Github gefunden werden.