Construindo um pipeline de dados com Flink e Kafka
1. Visão geral
Apache Flink é uma estrutura de processamento de fluxo que pode ser usada facilmente com Java. Apache Kafka é um sistema de processamento de fluxo distribuído com alta tolerância a falhas.
Neste tutorial, veremos como criar um pipeline de dados usando essas duas tecnologias.
2. Instalação
Para instalar e configurar o Apache Kafka, consulteofficial guide. Após a instalação, podemos usar os seguintes comandos para criar os novos tópicos chamadosflink_inputeflink_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
Por causa deste tutorial, usaremos a configuração padrão e as portas padrão para o Apache Kafka.
3. Uso Flink
O Apache Flink permite uma tecnologia de processamento de fluxo em tempo real. The framework allows using multiple third-party systems as stream sources or sinks.
No Flink - existem vários conectores disponíveis:
-
Apache Kafka (fonte / coletor)
-
Apache Cassandra (pia)
-
Amazon Kinesis Streams (origem / coletor)
-
Pesquisa elástica (coletor)
-
Hadoop FileSystem (coletor)
-
RabbitMQ (fonte / coletor)
-
Apache NiFi (fonte / coletor)
-
API de streaming do Twitter (fonte)
Para adicionar o Flink ao nosso projeto, precisamos incluir as seguintes dependências do Maven:
org.apache.flink
flink-core
1.5.0
org.apache.flink
flink-connector-kafka-0.11_2.11
1.5.0
A adição dessas dependências nos permitirá consumir e produzir tópicos de e para Kafka. Você pode encontrar a versão atual do Flink emMaven Central.
4. Consumidor de cordas Kafka
To consume data from Kafka with Flink we need to provide a topic and a Kafka address. Devemos também fornecer um id de grupo que será usado para manter os deslocamentos para que nem sempre leiamos todos os dados desde o início.
Vamos criar um método estático que tornará a criação deFlinkKafkaConsumer mais marítima:
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;
}
Este método pegatopic, kafkaAddress,ekafkaGroupe criaFlinkKafkaConsumer que consumirá dados de determinado tópico comoString, pois usamosSimpleStringSchema para decodificar os dados.
O número011 no nome da classe refere-se à versão do Kafka.
5. Kafka String Producer
To produce data to Kafka, we need to provide Kafka address and topic that we want to use. Novamente, podemos criar um método estático que nos ajudará a criar produtores para diferentes tópicos:
public static FlinkKafkaProducer011 createStringProducer(
String topic, String kafkaAddress){
return new FlinkKafkaProducer011<>(kafkaAddress,
topic, new SimpleStringSchema());
}
Este método leva apenastopicekafkaAddress como argumentos, uma vez que não há necessidade de fornecer o id do grupo quando estamos produzindo para o tópico Kafka.
6. Processamento de String Stream
Quando temos um consumidor e produtor em pleno funcionamento, podemos tentar processar dados do Kafka e salvar nossos resultados novamente no Kafka. A lista completa de funções que podem ser usadas para processamento de fluxo pode ser encontradahere.
Neste exemplo, vamos colocar as palavras em letras maiúsculas em cada entrada do Kafka e escrever de volta para o Kafka.
Para isso, precisamos criar umMapFunction personalizado:
public class WordsCapitalizer implements MapFunction {
@Override
public String map(String s) {
return s.toUpperCase();
}
}
Após criar a função, podemos usá-la no processamento de fluxo:
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);
}
O aplicativo lerá os dados do tópicoflink_input, executará operações no fluxo e salvará os resultados no parâmetroflink_output do Kafka.
Vimos como lidar com Strings usando Flink e Kafka. Mas muitas vezes é necessário realizar operações em objetos personalizados. Veremos como fazer isso nos próximos capítulos.
7. Desserialização de objeto personalizado
A classe a seguir representa uma mensagem simples com informações sobre remetente e destinatário:
@JsonSerialize
public class InputMessage {
String sender;
String recipient;
LocalDateTime sentAt;
String message;
}
Anteriormente, usávamosSimpleStringSchema para desserializar mensagens de Kafka, mas agorawe want to deserialize data directly to custom objects.
Para fazer isso, precisamos de umDeserializationSchema: personalizado
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);
}
}
Estamos assumindo aqui que as mensagens são mantidas como JSON em Kafka.
Como temos um campo do tipoLocalDateTime, precisamos especificar oJavaTimeModule, wh que faz o mapeamento dos objetosLocalDateTime para JSON.
Flink schemas can’t have fields that aren’t serializable porque todos os operadores (como esquemas ou funções) são serializados no início do trabalho.
Existem problemas semelhantes no Apache Spark. Uma das correções conhecidas para esse problema é inicializar os campos comostatic, como fizemos comObjectMapper acima. Não é a solução mais bonita, mas é relativamente simples e funciona.
O métodoisEndOfStream pode ser usado para o caso especial quando o fluxo deve ser processado apenas até que alguns dados específicos sejam recebidos. Mas não é necessário em nosso caso.
8. Serialização de objeto personalizado
Agora, vamos supor que queremos que nosso sistema tenha a possibilidade de criar um backup de mensagens. Queremos que o processo seja automático e que cada backup seja composto de mensagens enviadas durante um dia inteiro.
Além disso, uma mensagem de backup deve ter um ID exclusivo atribuído.
Para esse fim, podemos criar a seguinte classe:
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();
}
}
Lembre-se de que o mecanismo de geração de UUID não é perfeito, pois permite duplicatas. No entanto, isso é suficiente para o escopo deste exemplo.
Queremos salvar nosso objetoBackup como JSON para Kafka, então precisamos criar nossoSerializationSchema:
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. Mensagens de registro de data e hora
Como queremos criar um backup para todas as mensagens de cada dia, as mensagens precisam de um carimbo de data / hora.
Flink fornece as três características de tempo diferentesEventTime, ProcessingTime, areiaIngestionTime.
No nosso caso, precisamos usar a hora em que a mensagem foi enviada, então usaremosEventTime.
Para usarEventTimewe 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);
}
}
Precisamos transformar nossoLocalDateTime emEpochSecond, pois esse é o formato esperado pelo Flink. Depois de atribuir carimbos de data / hora, todas as operações baseadas em tempo usarão o tempo do camposentAt para operar.
Como o Flink espera que os timestamps estejam em milissegundos etoEpochSecond() retorna o tempo em segundos que precisamos multiplicar por 1000, então o Flink criará as janelas corretamente.
Flink define o conceito deWatermark. Watermarks are useful in case of data that don’t arrive in the order they were sent. Uma marca d'água define o atraso máximo permitido para que os elementos sejam processados.
Os elementos com carimbos de data / hora menores que a marca d'água não serão processados.
10. Criação de janelas de tempo
Para garantir que nosso backup reúna apenas as mensagens enviadas durante um dia, podemos usar o métodotimeWindowAll no stream, que dividirá as mensagens em janelas.
No entanto, ainda precisaremos agregar mensagens de cada janela e retorná-las comoBackup.
Para fazer isso, precisamos de umAggregateFunction personalizado:
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. Agregando backups
Depois de atribuir carimbos de data / hora adequados e implementar nossoAggregateFunction, podemos finalmente pegar nossa entrada Kafka e processá-la:
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. Conclusão
Neste artigo, apresentamos como criar um pipeline de dados simples com Apache Flink e Apache Kafka.
Como sempre, o código pode ser encontradoover on Github.