4.序列化-分区器-拦截器

序列化

生产者需要用序列化器(Serializer)将key和value序列化成字节数组才可以将消息传入Kafka。消费者需要用反序列化器(Deserializer)把从Kafka中收到的字节数组转化成相应的对象。在代码清单3-1中,key和value都使用了字符字符串,对应程序中的序列化器也使用了客户端自带的 StringSerializer,除了字符串类型的序列化器,还有 ByteArray、ByteBuffer、Bytes、Double、Integer、Long 这几种类型,它们都实现了 org.apache.kafka.common.serialization.Serializer 接口,此接口有3个方法:

// 用来配置当前类
public void configure(Map<String, ?> configs, boolean isKey)

// 用来执行序列化操作
public byte[] serializer(String topic, T data)

//用来关闭当前的序列化器
public void close()

一般情况下 close() 是个空方法,如果实现了此方法,则必须确保此方法的幂等性(一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外)。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。),因为这个方法可能会被 KafkaProducer调用多次(Serializer和KafkaProducer 所实现的接口都继承了 Closeable 接口)。

生产者使用的序列化和消费者使用的序列化是一一对应的,如果生产者使用了 StringSerializer,而消费者使用了另一种序列化器,那么是无法解析出相要数据的,本节主要讨论与生产者相关的,对于消费者的反序列化器请参见第9节。

以 StringSerializer 为例来看 Serializer接口中3个方法的使用方法,StringSerializer 类的具体实现代码如代码清单4-1:

// 代码清单4-1 StringSerializer的代码实现
public class StringSerializer implements Serializer<String> {
    private String encoding = "UTF8";

    public StringSerializer() {
    }

    public void configure(Map<String, ?> configs, boolean isKey) {
        String propertyName = isKey ? "key.serializer.encoding" : "value.serializer.encoding";
        Object encodingValue = configs.get(propertyName);
        if (encodingValue == null) {
            encodingValue = configs.get("serializer.encoding");
        }

        if (encodingValue != null && encodingValue instanceof String) {
            this.encoding = (String)encodingValue;
        }

    }

    public byte[] serialize(String topic, String data) {
        try {
            return data == null ? null : data.getBytes(this.encoding);
        } catch (UnsupportedEncodingException var4) {
            throw new SerializationException("Error when serializing string to byte[] due to unsupported encoding " + this.encoding);
        }
    }

    public void close() {
    }
}

首先是 configure() 方法,这个方法是在创建 KafkaProducer 实例的时候调用的,主要用来确定编码类型。不过一般客户端对于 key.serializer.encoding、value.serializer.encoding 和 serializer.encoding 都不会配置,在 KafkaProducer 的参数集合(ProducerConfig)里也没有这几个参数(它们可以看作用户自定义的参数),所以一般情况下 encoding 为默认的 “UTF-8”。serialize() 方法非常直观,就是将String 类型转换为 byte[] 类型。

如果Kafka客户端提供的几种序列化器都无法满足应用需求,可以选择如 Avro、JSON等通用的序列化工具实现,或者使用自定义类型的序列化器来实现。以下是自定义类型的简单例子:
假设我们发送的消息是 Company 对象,这个对象只有名称 name 和地址 address

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CompanyModel {

    private String name;
    private String address;
}

下面是 Company 对应的序列化器 CompanySerializer,示例代码如代码清单4-2所示。

// 代码情况4-2 自定义的序列化器CompanySerializer
public class CompanySerializer implements Serializer<CompanyModel> {

    @Override
    public void configure(Map<String, ?> map, boolean b) {

    }

    @Override
    public byte[] serialize(String s, CompanyModel companyModel) {
        if (companyModel==null){
            return null;
        }
        byte[] name,address;
        try {
            if (companyModel.getName()!=null){
                name=companyModel.getName().getBytes("UTF-8");
            }else {
                name=new byte[0];
            }

            if (companyModel.getAddress()!=null){
                address=companyModel.getAddress().getBytes("UTF-8");
            }else {
                address=new byte[0];
            }

            ByteBuffer buffer=ByteBuffer.allocate(4+4+name.length+address.length);
            buffer.putInt(name.length);
            buffer.put(name);
            buffer.putInt(address.length);
            buffer.put(address);
            return buffer.array();
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }

        return new byte[0];
    }

    @Override
    public void close() {

    }
}

如何使用自定义的序列化器 CompanySerializer呢?只需要将 KafkaProducer 的 value.serializer参数设置为 CompanySerializer 类的权限定名字即可。假如要发送一个 Company 对象到 Kafka,关键代码如代码清单4-3所示:

public class CompanyProducerTest {

    public static final String brokerList = "localhost:9092";
    public static final String topic="topic-demo";

    public static Properties initConfig(){
        Properties properties=new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG,brokerList);
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        //指定 CompanySerializer 自定义序列化器
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,"com.serializer.CompanySerializer");
        return properties;
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Producer<String, CompanyModel> producer=new KafkaProducer<>(initConfig());
        CompanyModel companyModel= CompanyModel.builder().name("hiddenkafka").address("China").build();
        producer.send(new ProducerRecord<>(topic,companyModel)).get();
    }

}

分区器

消息在通过 send() 发往 broker 的过程中,有可能需要经过拦截器(Interceptor)、序列化器(Serializer)和分区器(Partitioner)的一系列作用之后才能被真正地发往 broker。拦截器一般不是必须的,而序列化器是必须的。消息经过序列化之后就需要确定它发往的分区,如果消息 ProducerRecord 中指定了 partition 字段,那么就不需要分区器的作用,因为 partition 代表的就是要发往的分区号。

如果 ProducerRecode 中没有指定 partition 字段,那么就需要依赖分区器,根据 key 这个字段来计算 partition 的值。分区器的作用就是为消息分配分区。

Kafka 中提供的默认分区器是 org.apache.kafka.clients.producer.internals.DefaultPartitioner,它实现了 org.apache.kafka.clients.producer.Partitioner 接口,这个接口中定义了2个方法,具体如下所示。

public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster);
public void close();

partition() 方法用来计算分区号,返回值为ini类型。方法中的参数分别表示主题、键、序列化后的键、值、序列化后的值以及集群的元数据信息,通过这些信息可以实现分区器。close()方法在关闭分区器的时候回收一些资源。

Partitioner接口还有一个父接口 org.apache.kafka.common.Configurable,该接口只有一个方法

void configure(Map<String, ?> var1);

Configurable 接口中的 configure() 方法主要是用来获取配置信息及初始化数据。

在默认分区器 DefaultPartitioner 的实现中,close()是空方法,而 partition() 方法中定义了主要的分区分配逻辑。如果 key 不为 null,那么默认分区器会对 key 进行哈希(采用 MurmurHash2 算法,具备高运算性能及低碰撞率)根据最终得到的哈希值,与分区的数量取模运算得到分区编号来匹配分区,相同key得到的哈希值是一样的,所以当key一致,分区数量不变的情况下,会将消息写入同一个分区(注意:在不改变主题分区数量的情况下,key 与分区之间的映射可以保持不变。不过,一旦主题增加了分区,那么就难以保证key与分区的映射关系)。如果,key 是 null,那么消息会以轮询的方式写入分区。(注意:如果 key 不为null,那么计算得到的分区号会是所有分区中的一个。如果 key 为 null 并且有可用的分区的时候,那么计算得到的分区号仅为可用分区中的任意一个。)

除了使用 Kafka 提供的默认分区器进行分配,还可以使用自定义的分区器,只需要和 DefaultPartitioner 一样 实现 Partitioner 接口即可。默认的分区器在 key 为 null 不会选择不可用的分区,我们通过自定义分区器来实现,代码清单4-4为例:

public class DemoPartitioner implements Partitioner {

    private final AtomicInteger counter=new AtomicInteger(0);

    @Override
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes1, Cluster cluster) {
        //获取分区列表
        List<PartitionInfo> partitionInfos=cluster.partitionsForTopic(topic);
        //该主题下的分区数量
        int numPartitions=partitionInfos.size();
        if(keyBytes==null){
            return counter.getAndIncrement() % numPartitions;
        }else {
        	//对 keyBytes 进行 hash 选出一个 patition
            return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
        }
    }

    @Override
    public void close() {

    }

    @Override
    public void configure(Map<String, ?> map) {

    }
}
[AtomicInteger 类描述](https://blog.csdn.net/fanrenxiang/article/details/80623884)
[AtomicInteger getAndIncrement方法描述](https://blog.csdn.net/scmrpu/article/details/50819345)


实现自定义的 DemoPartitioner 类之后,需要通过配置参数 partitioner.class 来显式指定这个分区器:

properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, DemoPartitioner.class.getName());

这个自定义分区器的实现比较简单,读者也可以根据自身业务的需求来灵活实现分配分区的计算方式,比如一般大型电商都有多个仓库,可以将仓库的名称或 ID 作为 key 来灵活地记录商品信息。

拦截器

Kafka有两种拦截器:生产者拦截器和消费者拦截器
生产者拦截器既可以用来在消息发送前做一些准备工作,比如按照某个规定过滤不符合要求的消息、修改消息内容等,也可以用来在发送回调逻辑前做一些定制化的需求,比如统计类工作。

生产者拦截器的实现,主要是自定义实现 org.apache.kafka.clients.producer. ProducerInterceptor 接口。ProducerInterceptor接口包含3个方法:

	ProducerRecord<K, V> onSend(ProducerRecord<K, V> var1);

    void onAcknowledgement(RecordMetadata var1, Exception var2);

    void close();

KafkaProducer 在将消息序列化和计算分区之前会调用生产者拦截器的onSend()方法来对消息进行相应的定制化操作。一般来说最好不要修改消息 ProducerRecord 的 topic、key 和 pritition 等信息,如果修改需要保证对其有准确判断,否则会出现与预想不一致的偏差。比如修改 key 不仅会影响分区的计算还会影响 broker 端日志压缩(Log Compaction)功能。

KafkaProducer 会在消息被应答(Acknowledgement)之前或消息发送失败时调用生产者拦截器的 onAcknowledgement() 方法,优先于用户设定的 Callback 之前执行。这个方法运行在 Producer的I/O线程中,所以这个方法的实现逻辑约简单越好,否则会影响消息的发送。

close() 方法主要用于在关闭拦截器时执行一些资源的清理工作。在这3个方法中抛出的异常都会被捕获并记录到日志中,但并不会再向上传递。

ProducerInterceptor接口与Protitioner 接口一样都有一个父接口Configurable。

下面通过一个示例来演示生产者拦截器的使用,ProducerInterceptorPrefix通过onSend()方法为每条消息添加一个前缀“prefix1-”,并通过onAcknowledgement() 方法来计算消息发送的成功率。具体实现如代码清单4-5所示:

public class ProducerInterceptorPrefix implements ProducerInterceptor<String,String> {

    private volatile long sendSuccess = 0;
    private volatile long sendFailure = 0;

    @Override
    public ProducerRecord onSend(ProducerRecord producerRecord) {
        String modifiedValue="prefix1-"+producerRecord.value();

        return new ProducerRecord<String,String>
                (producerRecord.topic(),producerRecord.partition(), producerRecord.timestamp(),
                        (String) producerRecord.key(),modifiedValue);
    }

    @Override
    public void onAcknowledgement(RecordMetadata recordMetadata, Exception e) {
        if (e==null){
            sendSuccess++;
            System.out.println("此次发送成功");
        }else {
            sendFailure++;
            System.out.println("此次发送失败");
        }
    }

    @Override
    public void close() {
        double successRatio = (double)sendSuccess / (sendFailure + sendSuccess);
        System.out.println("[INFO] 发送成功率=" + String.format("%f", successRatio * 100) + "%");
    }

    @Override
    public void configure(Map<String, ?> map) {

    }
}

实现自定义的 ProducerInterceptorPrefix之后,需要在 KafkaProducer 的配置参数 interceptor.classes中指定拦截器:

properties.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, ProducerInterceptorPrefix.class.getName());

然后使用指定了 ProducerInterceptorPrefix 的生产者连续发送10条内容为“kafka”的消息,在发送完之后客户端打印出如下信息,说明发送成功,且 onAcknowledgement() 方法,优先于用户设定的 Callback 之前执行:
在这里插入图片描述
看消息是否被修改
在这里插入图片描述
Kafka中不仅可以指定一个拦截器还可以指定多个拦截器形成一个拦截器链。拦截器链会根据配置时配置的拦截器顺序来执行(配置的时候,各个拦截器之间使用逗号隔开)。下面在实现一个自定义拦截器ProducerInterceptorPrefixPlus,它只实现了**onSend()**方法,主要用来为每条消息添加另外一个前缀“prefix2-”:

@Override
    public ProducerRecord onSend(ProducerRecord producerRecord) {
        String modifiedValue="prefix2-"+producerRecord.value();

        return new ProducerRecord<String,String>
                (producerRecord.topic(),producerRecord.partition(), producerRecord.timestamp(),
                        (String) producerRecord.key(),modifiedValue);
    }

修改 KafkaProducer的配置信息 interceptor.classes配置:

properties.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, ProducerInterceptorPrefix.class.getName()+","+
                ProducerInterceptorPrefixPlus.class.getName());

此时生产者再连续发送10条内容为“kafka”的消息,那么最终消费者消费到的是10条内容为“prefix2-prefix1-kafka”的消息。

如果将 interceptor.classes 配置中的两个拦截器的位置互换:

properties.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, ProducerInterceptorPrefixPlus.class.getName()+","+
                ProducerInterceptorPrefix.class.getName());

那么最终消费者消费到的消息为“prefix1-prefix2-kafka”。
在这里插入图片描述
如果拦截器链中的某个拦截器的执行需要依赖上一个拦截器的输出,那么就有可能产生“副作用”。如果第一个拦截器因为异常执行失败,那么第二个也就不能继续执行。在拦截链中,如果某个拦截器执行失败,那么下一个拦截器会接着从上一个执行成功的拦截器继续执行。

发布了76 篇原创文章 · 获赞 1 · 访问量 5104

猜你喜欢

转载自blog.csdn.net/qq_38083545/article/details/92641838