Construire un pipeline de données avec Flink et Kafka

1. Vue d'ensemble

Apache Flink est un framework de traitement de flux qui peut être utilisé facilement avec Java. Apache Kafka est un système de traitement de flux distribué prenant en charge une tolérance de panne élevée.

Dans ce tutoriel, nous allons voir comment créer un pipeline de données à l'aide de ces deux technologies.

2. Installation

Pour installer et configurer Apache Kafka, reportez-vous auxofficial guide. Après l'installation, nous pouvons utiliser les commandes suivantes pour créer les nouveaux sujets appelésflink_input etflink_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

Pour les besoins de ce didacticiel, nous utiliserons la configuration par défaut et les ports par défaut pour Apache Kafka.

Apache Flink permet une technologie de traitement de flux en temps réel. The framework allows using multiple third-party systems as stream sources or sinks.

Dans Flink, différents connecteurs sont disponibles:

  • Apache Kafka (source / évier)

  • Apache Cassandra (évier)

  • Amazon Kinesis Streams (source / puits)

  • Elasticsearch (évier)

  • Hadoop FileSystem (évier)

  • RabbitMQ (source / évier)

  • Apache NiFi (source / puits)

  • API de streaming Twitter (source)

Pour ajouter Flink à notre projet, nous devons inclure les dépendances Maven suivantes:


    org.apache.flink
    flink-core
    1.5.0


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

L'ajout de ces dépendances nous permettra de consommer et de produire vers et à partir de thèmes Kafka. Vous pouvez trouver la version actuelle de Flink surMaven Central.

4. Consommateur de chaîne Kafka

To consume data from Kafka with Flink we need to provide a topic and a Kafka address. Nous devrions également fournir un identifiant de groupe qui sera utilisé pour conserver les décalages afin de ne pas toujours lire toutes les données depuis le début.

Créons une méthode statique qui rendra la création deFlinkKafkaConsumer plus facile:

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

Cette méthode prend untopic, kafkaAddress, et unkafkaGroup et crée lesFlinkKafkaConsumer qui consommeront les données d'un sujet donné en tant queString puisque nous avons utiliséSimpleStringSchema pour décoder les données.

Le nombre011 dans le nom de la classe fait référence à la version Kafka.

5. Producteur de cordes Kafka

To produce data to Kafka, we need to provide Kafka address and topic that we want to use. Encore une fois, nous pouvons créer une méthode statique qui nous aidera à créer des producteurs pour différents sujets:

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

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

Cette méthode ne prend quetopic etkafkaAddress comme arguments car il n’est pas nécessaire de fournir un identifiant de groupe lorsque nous produisons un sujet Kafka.

6. Traitement de flux de chaînes

Lorsque nous avons un consommateur et un producteur pleinement opérationnels, nous pouvons essayer de traiter les données de Kafka puis de sauvegarder nos résultats dans Kafka. La liste complète des fonctions qui peuvent être utilisées pour le traitement de flux peut être trouvéehere.

Dans cet exemple, nous allons mettre les mots en majuscules dans chaque entrée Kafka, puis les réécrire dans Kafka.

Pour cela, nous devons créer unMapFunction personnalisé:

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

Après avoir créé la fonction, nous pouvons l’utiliser dans le traitement de flux:

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

L'application lira les données de la rubriqueflink_input, effectuera des opérations sur le flux, puis enregistrera les résultats dans l'arrêtiqueflink_output de Kafka.

Nous avons vu comment gérer les chaînes en utilisant Flink et Kafka. Mais il est souvent nécessaire d’effectuer des opérations sur des objets personnalisés. Nous verrons comment procéder dans les prochains chapitres.

7. Désérialisation d'objets personnalisés

La classe suivante représente un message simple contenant des informations sur l'expéditeur et le destinataire:

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

Auparavant, nous utilisionsSimpleStringSchema pour désérialiser les messages de Kafka, mais maintenantwe want to deserialize data directly to custom objects.

Pour ce faire, nous avons besoin d'unDeserializationSchema: personnalisé

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

Nous supposons ici que les messages sont conservés en tant que JSON dans Kafka.

Puisque nous avons un champ de typeLocalDateTime, nous devons spécifier leJavaTimeModule, qui s'occupe de mapper les objetsLocalDateTime vers JSON.

Flink schemas can’t have fields that aren’t serializable car tous les opérateurs (comme les schémas ou les fonctions) sont sérialisés au début du travail.

Il existe des problèmes similaires dans Apache Spark. L'un des correctifs connus pour ce problème est l'initialisation des champs en tant questatic, comme nous l'avons fait avecObjectMapper ci-dessus. Ce n’est pas la solution la plus jolie, mais elle est relativement simple et fait le travail.

La méthodeisEndOfStream peut être utilisée pour le cas particulier où le flux doit être traité uniquement jusqu'à ce que certaines données spécifiques soient reçues. Mais ce n’est pas nécessaire dans notre cas.

8. Sérialisation d'objets personnalisés

Supposons maintenant que nous souhaitons que notre système ait la possibilité de créer une sauvegarde des messages. Nous voulons que le processus soit automatique et que chaque sauvegarde soit composée de messages envoyés au cours d'une journée entière.

En outre, un identifiant unique doit être attribué à un message de sauvegarde.

Pour cela, nous pouvons créer la classe suivante:

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

Veuillez noter que le mécanisme de génération d’UUID n’est pas parfait, car il autorise les doublons. Cependant, cela suffit pour la portée de cet exemple.

Nous voulons enregistrer notre objetBackup en tant que JSON dans Kafka, nous devons donc créer nosSerializationSchema:

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. Messages d'horodatage

Puisque nous voulons créer une sauvegarde pour tous les messages de chaque jour, les messages nécessitent un horodatage.

Flink fournit les trois caractéristiques temporelles différentesEventTime, ProcessingTime, andIngestionTime.

Dans notre cas, nous devons utiliser l'heure à laquelle le message a été envoyé, nous utiliserons doncEventTime.

Pour utiliserEventTimewe 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);
    }
}

Nous devons transformer nosLocalDateTime enEpochSecond car c'est le format attendu par Flink. Après l'attribution des horodatages, toutes les opérations basées sur le temps utiliseront le temps du champsentAt pour fonctionner.

Puisque Flink s'attend à ce que les horodatages soient en millisecondes et quetoEpochSecond() renvoie le temps en secondes, nous devions le multiplier par 1000, donc Flink créera correctement les fenêtres.

Flink définit le concept d'unWatermark. Watermarks are useful in case of data that don’t arrive in the order they were sent. Un filigrane définit le retard maximum autorisé pour les éléments à traiter.

Les éléments dont l'horodatage est inférieur au filigrane ne seront pas du tout traités.

10. Création de fenêtres horaires

Pour nous assurer que notre sauvegarde ne rassemble que les messages envoyés pendant une journée, nous pouvons utiliser la méthodetimeWindowAll sur le flux, qui divisera les messages en fenêtres.

Cependant, nous devrons toujours regrouper les messages de chaque fenêtre et les renvoyer sous la formeBackup.

Pour ce faire, nous aurons besoin d'unAggregateFunction personnalisé:

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. Agrégation des sauvegardes

Après avoir assigné les horodatages appropriés et implémenté nosAggregateFunction, nous pouvons enfin prendre notre entrée Kafka et la traiter:

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. Conclusion

Dans cet article, nous avons présenté comment créer un pipeline de données simple avec Apache Flink et Apache Kafka.

Comme toujours, le code peut être trouvéover on Github.