Построение конвейера данных с помощью Flink и Kafka

1. обзор

Apache Flink - это фреймворк потоковой обработки, который можно легко использовать с Java. Apache Kafka - это распределенная система потоковой обработки, поддерживающая высокую отказоустойчивость.

В этом уроке мы рассмотрим, как построить конвейер данных с использованием этих двух технологий.

2. Монтаж

Чтобы установить и настроить Apache Kafka, обратитесь кofficial guide. После установки мы можем использовать следующие команды для создания новых тем под названием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.

Apache Flink поддерживает технологию потоковой обработки в реальном времени. The framework allows using multiple third-party systems as stream sources or sinks.

В Flink - доступны различные разъемы:

  • Апач Кафка (источник / сток)

  • Апач Кассандра (раковина)

  • Amazon Kinesis Streams (источник / приемник)

  • Elasticsearch (раковина)

  • Файловая система Hadoop (приемник)

  • RabbitMQ (источник / приемник)

  • Apache NiFi (источник / приемник)

  • Twitter Streaming API (источник)

Чтобы добавить Flink в наш проект, нам нужно включить следующие зависимости Maven:


    org.apache.flink
    flink-core
    1.5.0


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

Добавление этих зависимостей позволит нам использовать темы Kafka и создавать их. Вы можете найти текущую версию Flink наMaven Central.

4. Потребитель строки Kafka

To consume data from Kafka with Flink we need to provide a topic and a Kafka address. Мы также должны предоставить идентификатор группы, который будет использоваться для хранения смещений, чтобы мы не всегда читали все данные с самого начала.

Давайте создадим статический метод, который упростит созданиеFlinkKafkaConsumer :

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

Этот метод принимаетtopic, kafkaAddress, иkafkaGroup и создаетFlinkKafkaConsumer, которые будут потреблять данные из данной темы какString, поскольку мы использовалиSimpleStringSchema для декодирования данных.

Число011 в имени класса относится к версии Kafka.

5. Струнный продюсер Кафки

To produce data to Kafka, we need to provide Kafka address and topic that we want to use. Опять же, мы можем создать статический метод, который поможет нам создавать производителей для разных тем:

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

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

Этот метод принимает в качестве аргументов толькоtopic иkafkaAddress, поскольку нет необходимости указывать идентификатор группы, когда мы создаем тему Kafka.

6. Обработка потока строк

Когда у нас есть полностью работающий потребитель и производитель, мы можем попытаться обработать данные из Kafka и затем сохранить наши результаты обратно в Kafka. Полный список функций, которые можно использовать для потоковой обработки, можно найти вhere.

В этом примере мы собираемся использовать слова в каждой записи Kafka с заглавной буквы, а затем записывать их обратно в Kafka.

Для этого нам нужно создать собственныйMapFunction:

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

После создания функции мы можем использовать ее в потоковой обработке:

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

Приложение будет читать данные из темыflink_input, выполнять операции с потоком, а затем сохранять результаты в стопикеflink_output в Kafka.

Мы видели, как работать со строками с помощью Flink и Kafka. Но часто требуется выполнять операции с настраиваемыми объектами. Мы увидим, как это сделать, в следующих главах.

7. Десериализация настраиваемого объекта

Следующий класс представляет простое сообщение с информацией об отправителе и получателе:

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

Раньше мы использовалиSimpleStringSchema для десериализации сообщений от Kafka, но теперьwe want to deserialize data directly to custom objects.

Для этого нам понадобится собственныйDeserializationSchema:

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

Здесь мы предполагаем, что сообщения хранятся в формате JSON в Kafka.

Поскольку у нас есть поле типаLocalDateTime, нам нужно указатьJavaTimeModule, w, который отвечает за сопоставление объектовLocalDateTime с JSON.

Flink schemas can’t have fields that aren’t serializable, потому что все операторы (например, схемы или функции) сериализуются в начале задания.

Есть похожие проблемы в Apache Spark. Одно из известных исправлений этой проблемы - инициализация полей какstatic, как мы сделали сObjectMapper выше. Это не самое красивое решение, но оно относительно простое и выполняет свою работу.

МетодisEndOfStream можно использовать в особом случае, когда поток должен обрабатываться только до тех пор, пока не будут получены определенные данные. Но в нашем случае это не нужно.

8. Сериализация пользовательских объектов

Теперь предположим, что мы хотим, чтобы в нашей системе была возможность создавать резервные копии сообщений. Мы хотим, чтобы процесс был автоматическим, и каждая резервная копия должна состоять из сообщений, отправленных в течение одного целого дня.

Кроме того, резервное сообщение должно иметь уникальный идентификатор.

Для этого мы можем создать следующий класс:

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

Помните, что механизм генерации UUID не идеален, так как он допускает дублирование. Однако этого достаточно для объема этого примера.

Мы хотим сохранить наш объектBackup как JSON в Kafka, поэтому нам нужно создать нашSerializationSchema:

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. Сообщения с отметками времени

Поскольку мы хотим создать резервную копию для всех сообщений каждого дня, сообщения должны иметь временную метку.

Flink обеспечивает три различные временные характеристикиEventTime, ProcessingTime, andIngestionTime.

В нашем случае нам нужно использовать время, когда сообщение было отправлено, поэтому мы будем использоватьEventTime.

Чтобы использоватьEventTimewe 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);
    }
}

Нам нужно преобразовать нашLocalDateTime вEpochSecond, поскольку это формат, ожидаемый Flink. После присвоения временных меток все операции, основанные на времени, будут использовать время из поляsentAt для работы.

Поскольку Flink ожидает, что временные метки будут в миллисекундах, аtoEpochSecond() возвращает время в секундах, нам нужно было умножить его на 1000, поэтому Flink будет правильно создавать окна.

Flink определяет концепциюWatermark. Watermarks are useful in case of data that don’t arrive in the order they were sent.. Водяной знак определяет максимальную задержку, которая разрешена для обработки элементов.

Элементы с отметками времени ниже водяного знака вообще не будут обрабатываться.

10. Создание окон времени

Чтобы гарантировать, что наша резервная копия собирает только сообщения, отправленные в течение одного дня, мы можем использовать методtimeWindowAll в потоке, который разделит сообщения на окна.

Однако нам все равно нужно будет агрегировать сообщения из каждого окна и возвращать их какBackup.

Для этого нам понадобится собственныйAggregateFunction:

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. Агрегирование резервных копий

После назначения надлежащих временных меток и реализации нашегоAggregateFunction, мы наконец можем взять наш ввод Kafka и обработать его:

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. Заключение

В этой статье мы представили, как создать простой конвейер данных с Apache Flink и Apache Kafka.

Как всегда, код можно найтиover on Github.