除了内置的客户端外,Kafka还提供了二进制连接协议,也就是说,我们直接向Kafka网络端口发送适当的字节序列,就可以从Kafka读取消息或往Kafka写入消息。因此还有好多语言实现的Kafka客户端,比如C++,Python,Go,不仅限于Java。
一个应用程序在很多情况下需要向Kafka写入消息:记录用户活动、保存日志信息、记录只能家电信息、与其他应用程序进行异步通信、缓冲即将写入数据库的数据,等等。
尽管生产者API使用起来很简单,但消息的发送过程还是挺复杂的。
1)我们首先创建一个 ProducerRecord 对象开始,ProducerRecord 对象需要包含目标主题和要发送的内容,还可以指定键和分区。
2)在发送 PR 时,生产者需要先把键值对象序列化成字节数组,这样才能在网络上传输。
3)接下来,数据被传给分区器。如果 PR 中已经指定了分区,那么分区器就不会再做任何事情了,直接把指定的分区返回。如果没有,分区器则会根据 PR 中的键来选择一个分区。
4)紧接着,这条记录就会添加到一个记录批次里,这个批次的所有消息都会被发送到相同的主题和分区里。
5)之后,有一个独立的线程将这些记录批次发送给相应的broker上。
6)服务器在收到这些消息时会返回一个响应,如果消息发送成功写入Kafka中,就会返回一个 RecordMetaData 对象,它包含着主题和分区信息,以及记录在分区里的偏移量。
想要写入Kafka,首先要创建一个生产者对象,并设置一些属性。Kafka生产者有3个必选属性。
bootstrap.servers
该属性指定 broker 的地址清单,地址格式为:host:port。建议提供两个broker信息,其中一个宕机,生产者仍然能够连接到集群上。
key.serializer
broker希望接收到的消息的键值都是字节数组,不过生产者需要将键值所包装的Java对象转成字节数组。Kafka客户端默认提供 ByteArraySerializer、StringSerializer 和 IntegerSerializer 的序列化器。
value.serializer
如果键是整数类型而值是字符串,那么需要使用不同的序列化器。
Properties kafkaProps = new Properties();
kafkaProps.put("bootstrap.servers","hadoop102:9092,hadoop103:9092");
kafkaProps.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
kafkaProps.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
KafkaProducer<String,String> producer = new KafkaProducer<String, String>(kafkaProps);
此时实例化生产者后,接下来就可以发送开始发送消息了。
发送消息有以下3种方式。
//发送并忘记
producer.send(record);
//同步发送
try {
RecordMetadata metadata = producer.send(record).get();
Long offset = metadata.offset();
} catch (Exception e) {
e.printStackTrace();
}
//异步发送
producer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata recordMetadata, Exception e) {
if (e != null){
e.printStackTrace();
}
}
});
Kafka 可以保证在同一个分区内的顺序是一致的。
有些场景对于顺序是非常重要的,比如银行。
因此如果 retries 参数设置成非零整数,同时把 max.in.flight.requeset.per.connection 设为比1大的数,那么如果第一个批次的消息写入失败,而第二次的消息写入成功,而broker将重新写入第一批次。那么此时两个批次的顺序就会反过来。
因此,如果某些场景要求消息是有序的,消息写入成功也是很重要的,所以不建议把 reties 设为0,但可以把 request.per.connection 设为 1,这样生产者发送第一批消息时,就不会有其他消息发送给broker。
之前我们讲到实例化一个生产者,就必须要指定一个序列化器,虽然Kafka提供了默认的字符串序列化器,整型和字节数组序列化器,不过不足以满足大部分场景的需求,因为我们要序列化的记录类型越来越多,越来越复杂。
如果我们发送到 Kafka 的对象如果不是简单字符串和整型,那么可以使用序列化框架,如 Arvo、Thrift、Protobuf,或者使用自定义序列化器。当然使用现成的序列化框架最好。
如果我们需要发送的消息是一个Customer对象的话
/**
* @Author Juniors Lee
* @Date 2021/11/16
*/
public class Customer {
private int customID;
private String customerName;
public int getCustomID() {
return customID;
}
public void setCustomID(int customID) {
this.customID = customID;
}
public String getCustomerName() {
return customerName;
}
public void setCustomerName(String customerName) {
this.customerName = customerName;
}
}
因此我们要自定义的序列化器。
/**
* @Author Juniors Lee
* @Date 2021/11/16
*/
public class CustomerSerializer implements Serializer<Customer> {
@Override
public void configure(Map<String, ?> map, boolean b) {
//不做任何配置
}
@Override
/**
* Customer 对象被序列化成:
* 表示 customerID 的4字节整数
* 表示 customerName 长度的4字节整数
* 表示 customerName 的N个字节
*/
public byte[] serialize(String s, Customer customer) {
byte[] serializedName;
int stringSize;
if (customer == null)
return null;
else {
if (customer.getCustomerName() != null){
serializedName = customer.getCustomerName().getBytes(StandardCharsets.UTF_8);
stringSize = serializedName.length;
}else {
serializedName = new byte[0];
stringSize = 0;
}
}
ByteBuffer buffer = ByteBuffer.allocate(4 + 4 + stringSize);
buffer.putInt(customer.getCustomID());
buffer.putInt(stringSize);
buffer.put(serializedName);
return buffer.array();
}
@Override
public void close() {
//不需要关闭任何东西
}
}
Apache Avro 是一种与编程语言无关的序列化格式。Doug Cutting创建的这个给项目,目的是为了一种共享数据文件的形式。
Avro 数据是通过与语言无关的 schema 来定义的。而 schema 是通过 JSON 来描述,数据被序列化成二进制或JSON文件。
Avro 有一个很有意思的特性是,当负责写消息的应用程序使用了新 schema,负责读的应用程序可以继续处理消息而无需做出任何改动,这点很适合Kafka上使用。
ProducerRecord对象包括了目标主题、键和值。Kafka的消息是一个个键值对,当然键可以为null。不过大部分的应用程序都要用到键,一是作为消息的附带信息,二是能够用来决定消息该写到主题的哪个分区,相同的键分配到相同的分区中。一个进程只会从一个主题中读取数据。
如果键值为 null,而且使用了默认的分区器,那么记录将被随机发送到主题内各个可用的分区上。分区器使用的是轮询(Round Robin)算法将消息均衡地发布在各个分区上。
当然如果键不为null,而且使用的默认分区器,那么Kafka会对键进行散列,然后根据散列值将消息映射到特定的分区上,而这样的映射,我们将用到主题所有的分区,而不仅仅是可用的分区。这也意味着,如果写入数据的分区也可能是不可用的,当然这个情况很少发生。
只有在不改变主题分区的数量的情况下,键与分区之间的映射才能保持不变,因此最好在创建主题的时候就把分区规划好。
/**
* @Author Juniors Lee
* @Date 2021/11/16
*/
public class BananaPartitioner implements Partitioner {
@Override
public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster){
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
int numPartitions = partitions.size();
if ((keyBytes == null) || (!(key instanceof String)))
throw new InvalidRecordException("We expect all messages to have customer name as key")
if (((String)key).equals("Banana"))
return numPartitions; //Banana总是分配到最后一个分区
//其他记录被散列在其他分区
return (Math.abs(Utils.murmur2(keyBytes)) % (numPartitions - 1));
}
@Override
public void close() {
}
@Override
public void configure(Map<String, ?> map) {
}
}